QA hardening: security RLS fixes, Flutter 3.47.5 upgrade, UI/validation fixes
Security — enforce write authorization server-side (was UI/RPC-only): - it_service_requests RLS: block cross-office read/edit + self-approve (QA-015) - pass_slips RLS: owner can complete but not self-approve (QA-046) - swap_requests RLS: scope select/update to participants + admin (QA-047) - storage: tighten it_service_attachments + task_attachments write/delete (QA-027) - admin_user_management edge function: allow programmers to manage users (QA-016) Fixes: - workforce generator "uncovered shifts" false alarms (QA-043/044) - network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048) - de-flake time-of-day-dependent dashboard metrics test (QA-045) Toolchain: - upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0, flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002) analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/. Note: also carries the in-progress Brick model cleanup already present in the working tree. QA-001 (AI keys public in the build) is deferred by owner decision. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/services_provider.dart';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../models/user_office.dart';
|
||||
import '../../providers/admin_user_provider.dart';
|
||||
@@ -713,9 +714,7 @@ String _formatLastActiveLabel(DateTime? value) {
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}m ago';
|
||||
if (diff.inDays < 1) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
return '${value.year}-$month-$day';
|
||||
return DateFormat('yyyy-MM-dd').format(value);
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/announcement_comment.dart';
|
||||
import '../../models/announcement_comment.model.dart';
|
||||
import '../../providers/announcements_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
@@ -49,10 +49,9 @@ class _AnnouncementCommentsSectionState
|
||||
// Comment was posted; only push notification delivery failed.
|
||||
_controller.clear();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Comment posted, but notifications may not have been sent.'),
|
||||
),
|
||||
showInfoSnackBar(
|
||||
context,
|
||||
'Comment posted, but notifications may not have been sent.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -225,7 +224,7 @@ class _CommentTile extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
_relativeTime(comment.createdAt),
|
||||
AppTime.relative(comment.createdAt),
|
||||
style: tt.labelSmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -252,12 +251,3 @@ class _CommentTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _relativeTime(DateTime dt) {
|
||||
final now = AppTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 1) return 'just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
return AppTime.formatDate(dt);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'dart:math' as math show min;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/announcement.dart';
|
||||
import '../../models/announcement.model.dart';
|
||||
import '../../providers/announcements_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
@@ -381,7 +381,7 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
_relativeTime(widget.announcement.createdAt),
|
||||
AppTime.relative(widget.announcement.createdAt),
|
||||
style: tt.labelSmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
@@ -578,16 +578,6 @@ class _AnnouncementCardState extends ConsumerState<_AnnouncementCard> {
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
String _relativeTime(DateTime dt) {
|
||||
final now = AppTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
return AppTime.formatDate(dt);
|
||||
}
|
||||
|
||||
String _roleLabel(String role) {
|
||||
const labels = {
|
||||
'admin': 'Admin',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/announcement.dart';
|
||||
import '../../models/announcement.model.dart';
|
||||
import '../../providers/announcements_provider.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
@@ -260,12 +260,9 @@ class _CreateAnnouncementContentState
|
||||
widget.editing != null ? 'Announcement updated.' : 'Announcement posted.');
|
||||
} on AnnouncementNotificationException {
|
||||
if (mounted) {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
Navigator.of(context).pop();
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Posted, but some notifications failed to send.'),
|
||||
),
|
||||
showInfoSnackBarGlobal(
|
||||
'Posted, but some notifications failed to send.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,11 +8,11 @@ import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../../models/attendance_log.dart';
|
||||
import '../../models/duty_schedule.dart';
|
||||
import '../../models/leave_of_absence.dart';
|
||||
import '../../models/pass_slip.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/attendance_log.model.dart';
|
||||
import '../../models/duty_schedule.model.dart';
|
||||
import '../../models/leave_of_absence.model.dart';
|
||||
import '../../models/pass_slip.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/rotation_config.dart';
|
||||
import '../../providers/attendance_provider.dart';
|
||||
import '../../providers/debug_settings_provider.dart';
|
||||
@@ -26,8 +26,8 @@ import '../../providers/whereabouts_provider.dart';
|
||||
import '../../providers/workforce_provider.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/swap_request.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/swap_request.model.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import 'dtr_pdf.dart';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/attendance_log.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/pass_slip.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/attendance_log.model.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/pass_slip.model.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
@@ -392,16 +393,8 @@ class LogbookDayActivitySheet extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatDate(DateTime d) {
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
];
|
||||
final wd = weekdays[d.weekday - 1];
|
||||
final mo = months[d.month - 1];
|
||||
return '$wd, $mo ${d.day}, ${d.year}';
|
||||
}
|
||||
static String _formatDate(DateTime d) =>
|
||||
DateFormat('EEE, MMM d, yyyy', 'en_US').format(d);
|
||||
|
||||
static String _initials(String name) {
|
||||
final parts = name.trim().split(RegExp(r'\s+'));
|
||||
|
||||
@@ -8,14 +8,14 @@ import 'package:intl/intl.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../models/attendance_log.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/it_service_request_assignment.dart';
|
||||
import '../../models/pass_slip.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/task_activity_log.dart';
|
||||
import '../../models/attendance_log.model.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/it_service_request_assignment.model.dart';
|
||||
import '../../models/pass_slip.model.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/task_activity_log.model.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../providers/attendance_provider.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../providers/pass_slip_provider.dart';
|
||||
@@ -24,6 +24,7 @@ import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import 'work_log_pdf.dart';
|
||||
|
||||
// ─── Domain types ────────────────────────────────────────────────────────────
|
||||
@@ -674,9 +675,7 @@ class _WorkLogTabState extends ConsumerState<WorkLogTab> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Export failed: $e')),
|
||||
);
|
||||
showErrorSnackBar(context, 'Export failed: $e');
|
||||
}
|
||||
} finally {
|
||||
entry.remove();
|
||||
@@ -691,7 +690,6 @@ class _WorkLogTabState extends ConsumerState<WorkLogTab> {
|
||||
String personName,
|
||||
String rangeLabel,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
final range = _effectiveDateRange;
|
||||
final modeLabel = switch (_viewMode) {
|
||||
@@ -730,9 +728,7 @@ class _WorkLogTabState extends ConsumerState<WorkLogTab> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Export failed: $e')),
|
||||
);
|
||||
showErrorSnackBar(context, 'Export failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,7 +858,6 @@ class _WorkLogTabState extends ConsumerState<WorkLogTab> {
|
||||
String personName,
|
||||
List<_DailySummaryData> multiDaySummaries,
|
||||
) async {
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
try {
|
||||
late Uint8List bytes;
|
||||
if (!_isMultiDay || _isDrilling) {
|
||||
@@ -901,9 +896,7 @@ class _WorkLogTabState extends ConsumerState<WorkLogTab> {
|
||||
);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text('Failed to generate PDF: $e')),
|
||||
);
|
||||
showErrorSnackBar(context, 'Failed to generate PDF: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,9 +72,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
if (response.session != null && mounted) {
|
||||
context.go('/tickets');
|
||||
} else if (mounted) {
|
||||
// On success the router's auth redirect sends each role to its landing
|
||||
// route (_defaultRouteForRole), so don't navigate here.
|
||||
if (response.session == null && mounted) {
|
||||
showInfoSnackBar(context, 'Check your email to confirm sign-in.');
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
@@ -205,6 +205,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _obscurePassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
|
||||
@@ -202,6 +202,9 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _obscurePassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_outlined
|
||||
@@ -280,6 +283,9 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
|
||||
labelText: 'Confirm password',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _obscureConfirm
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
icon: Icon(
|
||||
_obscureConfirm
|
||||
? Icons.visibility_outlined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -5,18 +6,18 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/attendance_log.dart';
|
||||
import '../../models/duty_schedule.dart';
|
||||
import '../../models/leave_of_absence.dart';
|
||||
import '../../models/attendance_log.model.dart';
|
||||
import '../../models/duty_schedule.model.dart';
|
||||
import '../../models/leave_of_absence.model.dart';
|
||||
import '../../models/live_position.dart';
|
||||
import '../../models/pass_slip.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/pass_slip.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/it_service_request_assignment.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/it_service_request_assignment.model.dart';
|
||||
import '../../providers/attendance_provider.dart';
|
||||
import '../../providers/leave_provider.dart';
|
||||
import '../../providers/pass_slip_provider.dart';
|
||||
@@ -27,7 +28,7 @@ import '../../providers/whereabouts_provider.dart';
|
||||
import '../../providers/workforce_provider.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../providers/teams_provider.dart';
|
||||
import '../../models/team.dart';
|
||||
import '../../models/team.model.dart';
|
||||
import '../../models/team_member.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../widgets/responsive_body.dart';
|
||||
@@ -407,10 +408,7 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||
AppTime.now().difference(livePos.updatedAt) >
|
||||
const Duration(minutes: 15);
|
||||
if (stale) {
|
||||
final diff = AppTime.now().difference(livePos.updatedAt);
|
||||
final ago = diff.inMinutes < 60
|
||||
? '${diff.inMinutes}m ago'
|
||||
: '${diff.inHours}h ago';
|
||||
final ago = AppTime.relative(livePos.updatedAt);
|
||||
whereabouts = livePos.inPremise
|
||||
? 'Last seen in premise \u00b7 $ago'
|
||||
: 'Last seen outside \u00b7 $ago';
|
||||
@@ -628,6 +626,8 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// Sound/vibration settings and openAppSettings() don't apply on web.
|
||||
if (kIsWeb) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final seen = prefs.getBool('has_seen_notif_showcase') ?? false;
|
||||
if (!seen) {
|
||||
@@ -1110,36 +1110,24 @@ class _StaffRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// IT Staff cell: avatar on mobile, name on desktop, with team color dot
|
||||
Widget staffCell;
|
||||
if (isMobile) {
|
||||
staffCell = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
teamMarker,
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Tooltip(
|
||||
message: row.name,
|
||||
child: ProfileAvatar(
|
||||
fullName: row.name,
|
||||
avatarUrl: row.avatarUrl,
|
||||
radius: 14,
|
||||
),
|
||||
),
|
||||
// IT Staff cell: name (ellipsized on narrow screens) with team color dot.
|
||||
// Mobile used initials-only avatars, which are ambiguous and need a
|
||||
// long-press tooltip to identify anyone.
|
||||
final staffCell = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
teamMarker,
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
row.name,
|
||||
style: valueStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
staffCell = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
teamMarker,
|
||||
const SizedBox(width: 6),
|
||||
Flexible(child: Text(row.name, style: valueStyle)),
|
||||
],
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
|
||||
@@ -9,12 +9,12 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/it_service_request_assignment.dart';
|
||||
import '../../models/it_service_request_activity_log.dart';
|
||||
import '../../models/it_service_request_action.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/it_service_request_assignment.model.dart';
|
||||
import '../../models/it_service_request_activity_log.model.dart';
|
||||
import '../../models/it_service_request_action.model.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
@@ -22,6 +22,7 @@ import '../../providers/supabase_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../services/ai_service.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/debouncer.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/gemini_animated_text_field.dart';
|
||||
@@ -85,7 +86,7 @@ class _ItServiceRequestDetailScreenState
|
||||
|
||||
Timer? _eventDetailsDebounce;
|
||||
Timer? _remarksDebounce;
|
||||
Timer? _actionDebounce;
|
||||
final _actionDebounce = Debouncer(const Duration(milliseconds: 800));
|
||||
|
||||
String _eventDetailsLastPlain = '';
|
||||
String _remarksLastPlain = '';
|
||||
@@ -147,7 +148,7 @@ class _ItServiceRequestDetailScreenState
|
||||
_actionTakenController?.dispose();
|
||||
_eventDetailsDebounce?.cancel();
|
||||
_remarksDebounce?.cancel();
|
||||
_actionDebounce?.cancel();
|
||||
_actionDebounce.cancel();
|
||||
_savedBadgeTimer?.cancel();
|
||||
_saveAnimController.dispose();
|
||||
_tabController.dispose();
|
||||
@@ -1388,8 +1389,7 @@ class _ItServiceRequestDetailScreenState
|
||||
final plain = _actionTakenController!.document.toPlainText().trim();
|
||||
if (plain == _actionLastPlain) return;
|
||||
_actionLastPlain = plain;
|
||||
_actionDebounce?.cancel();
|
||||
_actionDebounce = Timer(const Duration(milliseconds: 800), () async {
|
||||
_actionDebounce.run(() async {
|
||||
final delta = _actionTakenController!.document.toDelta();
|
||||
final deltaJson = jsonEncode(delta.toJson());
|
||||
if (mounted) {
|
||||
|
||||
@@ -8,10 +8,10 @@ import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:pdf/pdf.dart' as pdf;
|
||||
import 'package:printing/printing.dart';
|
||||
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/it_service_request_assignment.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/it_service_request_assignment.model.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
/// Build PDF bytes for IT Service Request Form.
|
||||
|
||||
@@ -5,10 +5,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/it_service_request.dart';
|
||||
import '../../models/it_service_request_assignment.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/it_service_request.model.dart';
|
||||
import '../../models/it_service_request_assignment.model.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../brick/cache_helpers.dart';
|
||||
import '../../providers/it_service_request_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
@@ -430,9 +430,16 @@ class _ItServiceRequestsListScreenState
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Create'),
|
||||
// Disabled until named, so the dialog never closes and drops
|
||||
// the user's input on a validation failure.
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: nameController,
|
||||
builder: (context, value, child) => FilledButton(
|
||||
onPressed: value.text.trim().isEmpty
|
||||
? null
|
||||
: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -442,10 +449,6 @@ class _ItServiceRequestsListScreenState
|
||||
);
|
||||
|
||||
if (result != true || !context.mounted) return;
|
||||
if (nameController.text.trim().isEmpty) {
|
||||
showWarningSnackBar(context, 'Event name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final ctrl = ref.read(itServiceRequestControllerProvider);
|
||||
|
||||
@@ -112,6 +112,7 @@ class _NetworkMapDeviceEditScreenState
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name (e.g. Floor 3, Rack U-12)',
|
||||
),
|
||||
onChanged: (_) => setLocal(() {}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<NetworkLocationKind>(
|
||||
@@ -135,7 +136,11 @@ class _NetworkMapDeviceEditScreenState
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
// Keep Create disabled until a name is entered, instead of
|
||||
// silently discarding an empty submit after close (QA-048).
|
||||
onPressed: nameCtrl.text.trim().isEmpty
|
||||
? null
|
||||
: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../models/network/network_port.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/network_map/network_import_provider.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import 'widgets/import_diff_view.dart';
|
||||
|
||||
@@ -247,12 +248,11 @@ class NetworkMapImportReviewScreen extends ConsumerWidget {
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(
|
||||
'Applied ${createdDeviceIds.length} devices, '
|
||||
'${createdLinkIds.length} links.',
|
||||
),
|
||||
));
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
'Applied ${createdDeviceIds.length} devices, '
|
||||
'${createdLinkIds.length} links.',
|
||||
);
|
||||
context.go('/network-map');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../models/network/network_device.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
@@ -276,9 +277,7 @@ class _UnassignedSection extends ConsumerWidget {
|
||||
) async {
|
||||
final sites = ref.read(networkSitesProvider).valueOrNull ?? const [];
|
||||
if (sites.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Create a site first.')),
|
||||
);
|
||||
showWarningSnackBar(context, 'Create a site first.');
|
||||
return;
|
||||
}
|
||||
String? picked = sites.first.id;
|
||||
@@ -321,9 +320,7 @@ class _UnassignedSection extends ConsumerWidget {
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Moved ${devices.length} devices.')),
|
||||
);
|
||||
showSuccessSnackBar(context, 'Moved ${devices.length} devices.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,40 +98,56 @@ class NetworkMapVlanScreen extends ConsumerWidget {
|
||||
final descCtrl = TextEditingController();
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('New VLAN'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: idCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'VLAN ID (1-4094)'),
|
||||
autofocus: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
// Keep Create disabled until the input is valid, instead of accepting
|
||||
// the click and silently discarding out-of-range/empty input after the
|
||||
// dialog closes (QA-048; matches the QA-029 disabled-until-valid fix).
|
||||
builder: (ctx, setDialogState) {
|
||||
final parsedId = int.tryParse(idCtrl.text.trim());
|
||||
final isValid = parsedId != null &&
|
||||
parsedId >= 1 &&
|
||||
parsedId <= 4094 &&
|
||||
nameCtrl.text.trim().isNotEmpty;
|
||||
return AlertDialog(
|
||||
title: const Text('New VLAN'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: idCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'VLAN ID (1-4094)'),
|
||||
autofocus: true,
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
onChanged: (_) => setDialogState(() {}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: descCtrl,
|
||||
decoration:
|
||||
const InputDecoration(labelText: 'Description (optional)'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: isValid ? () => Navigator.pop(ctx, true) : null,
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
final vlanId = int.tryParse(idCtrl.text.trim());
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../models/network/network_link.dart';
|
||||
import '../../../models/network/network_port.dart';
|
||||
import '../../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../../utils/snackbar.dart';
|
||||
|
||||
/// Opens an M3 dialog to connect [portId] to a port on another device.
|
||||
///
|
||||
@@ -69,12 +70,7 @@ class _LinkEditDialogState extends ConsumerState<_LinkEditDialog> {
|
||||
if (mounted) Navigator.of(context).pop(true);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to create link: $e'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
showErrorSnackBar(context, 'Failed to create link: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
|
||||
@@ -128,7 +128,7 @@ class _TopologyLegendState extends State<TopologyLegend>
|
||||
opacity: _fade,
|
||||
child: SizeTransition(
|
||||
sizeFactor: _fade,
|
||||
axisAlignment: -1,
|
||||
alignment: const Alignment(-1.0, -1.0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Material(
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/notification_item.model.dart';
|
||||
import '../../services/notification_service.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
|
||||
@@ -2,8 +2,9 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../providers/auth_provider.dart' show sessionProvider;
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
@@ -426,9 +427,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime dt) {
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
String _formatDate(DateTime dt) => DateFormat('yyyy-MM-dd').format(dt);
|
||||
|
||||
Future<void> _pickAvatar(ImageSource source) async {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../providers/reports_provider.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import 'report_date_filter.dart';
|
||||
import 'report_widget_selector.dart';
|
||||
@@ -59,9 +60,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||
await ReportPdfExport.sharePdf(pdfBytes);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('PDF export failed: $e')));
|
||||
showErrorSnackBar(context, 'PDF export failed: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _exporting = false);
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
import '../../models/task.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
|
||||
+2098
-2024
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,11 @@ import 'package:pdf/pdf.dart' as pdf;
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
|
||||
import '../../models/task.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/task_activity_log.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../models/task_activity_log.model.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
Future<Uint8List> buildTaskPdfBytes(
|
||||
|
||||
@@ -7,14 +7,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/notification_item.model.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/task_assignment.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
@@ -1411,14 +1412,8 @@ String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime value) {
|
||||
final year = value.year.toString().padLeft(4, '0');
|
||||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
final hour = value.hour.toString().padLeft(2, '0');
|
||||
final minute = value.minute.toString().padLeft(2, '0');
|
||||
return '$year-$month-$day $hour:$minute';
|
||||
}
|
||||
String _formatTimestamp(DateTime value) =>
|
||||
DateFormat('yyyy-MM-dd HH:mm').format(value);
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/team.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/team.model.dart';
|
||||
import '../../providers/teams_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
|
||||
@@ -4,9 +4,9 @@ import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../models/ticket_message.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/supabase_provider.dart';
|
||||
|
||||
@@ -3,17 +3,19 @@ import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/office.model.dart';
|
||||
import '../../widgets/office_picker.dart';
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../models/notification_item.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../providers/user_offices_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
@@ -288,7 +290,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
TasQColumn<Ticket>(
|
||||
header: 'Ticket ID',
|
||||
technical: true,
|
||||
cellBuilder: (context, ticket) => Text(ticket.id),
|
||||
cellBuilder: (context, ticket) => Tooltip(
|
||||
message: ticket.id,
|
||||
child: Text(_shortId(ticket.id)),
|
||||
),
|
||||
),
|
||||
TasQColumn<Ticket>(
|
||||
header: 'Subject',
|
||||
@@ -362,7 +367,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
const SizedBox(height: 2),
|
||||
Text('Filed by: $assigned'),
|
||||
const SizedBox(height: 4),
|
||||
MonoText('ID ${ticket.id}'),
|
||||
MonoText('ID ${_shortId(ticket.id)}'),
|
||||
const SizedBox(height: 2),
|
||||
Text(_formatTimestamp(ticket.createdAt)),
|
||||
],
|
||||
@@ -450,6 +455,17 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
final profile = ref.watch(currentProfileProvider).valueOrNull;
|
||||
final isGlobal = const {
|
||||
'admin',
|
||||
'programmer',
|
||||
'dispatcher',
|
||||
'it_staff',
|
||||
}.contains(profile?.role);
|
||||
final myOfficeIds = {
|
||||
for (final a in ref.watch(userOfficesProvider).valueOrNull ?? [])
|
||||
if (a.userId == profile?.id) a.officeId,
|
||||
};
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -468,8 +484,18 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) return const Text('No offices assigned.');
|
||||
data: (allOffices) {
|
||||
// ticketsProvider only shows non-global users their own
|
||||
// offices' tickets, so only offer those offices here —
|
||||
// otherwise the new ticket vanishes from the filer's list.
|
||||
final offices = isGlobal
|
||||
? allOffices
|
||||
: allOffices
|
||||
.where((o) => myOfficeIds.contains(o.id))
|
||||
.toList();
|
||||
if (offices.isEmpty) {
|
||||
return const Text('No offices assigned.');
|
||||
}
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return OfficeSelectorField(
|
||||
@@ -761,6 +787,11 @@ class _StatusSummaryCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// First 8 chars of the UUID, like a short git hash; the full id is in the
|
||||
/// tooltip. Tickets have no human-friendly number.
|
||||
String _shortId(String id) =>
|
||||
id.length > 8 ? id.substring(0, 8).toUpperCase() : id;
|
||||
|
||||
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return 'Unassigned';
|
||||
@@ -772,14 +803,8 @@ String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||
}
|
||||
|
||||
String _formatTimestamp(DateTime value) {
|
||||
final year = value.year.toString().padLeft(4, '0');
|
||||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
final hour = value.hour.toString().padLeft(2, '0');
|
||||
final minute = value.minute.toString().padLeft(2, '0');
|
||||
return '$year-$month-$day $hour:$minute';
|
||||
}
|
||||
String _formatTimestamp(DateTime value) =>
|
||||
DateFormat('yyyy-MM-dd HH:mm').format(value);
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
@@ -4,9 +4,9 @@ import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../models/app_settings.dart';
|
||||
import '../../models/attendance_log.dart';
|
||||
import '../../models/attendance_log.model.dart';
|
||||
import '../../models/live_position.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../providers/attendance_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/whereabouts_provider.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/rotation_config.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/rotation_config_provider.dart';
|
||||
@@ -287,9 +287,8 @@ class _RotationOrderTabState extends ConsumerState<_RotationOrderTab> {
|
||||
child: ReorderableListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
itemCount: order.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = order.removeAt(oldIndex);
|
||||
order.insert(newIndex, item);
|
||||
});
|
||||
@@ -422,9 +421,8 @@ class _FridayAmTabState extends ConsumerState<_FridayAmTab> {
|
||||
child: ReorderableListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
itemCount: order.length,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
onReorderItem: (oldIndex, newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final item = order.removeAt(oldIndex);
|
||||
order.insert(newIndex, item);
|
||||
});
|
||||
@@ -1394,7 +1392,9 @@ class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> {
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
onPressed: name.trim().isEmpty
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Add'),
|
||||
),
|
||||
],
|
||||
@@ -1404,7 +1404,7 @@ class _HolidaySettingsTabState extends ConsumerState<_HolidaySettingsTab> {
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true || name.isEmpty) return;
|
||||
if (confirmed != true || name.trim().isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_holidays = [
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
import '../../models/duty_schedule.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/duty_schedule.model.dart';
|
||||
import '../../models/profile.model.dart';
|
||||
import '../../models/rotation_config.dart';
|
||||
import '../../models/swap_request.dart';
|
||||
import '../../models/swap_request.model.dart';
|
||||
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/rotation_config_provider.dart';
|
||||
@@ -1005,7 +1005,13 @@ class _ScheduleGeneratorPanelState
|
||||
rotationConfig: rotationConfig,
|
||||
);
|
||||
generated.sort((a, b) => a.startTime.compareTo(b.startTime));
|
||||
final warnings = _buildWarnings(start, end, generated, rotationConfig);
|
||||
final warnings = _buildWarnings(
|
||||
start,
|
||||
end,
|
||||
generated,
|
||||
schedules,
|
||||
rotationConfig,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -1229,6 +1235,7 @@ class _ScheduleGeneratorPanelState
|
||||
_startDate ?? AppTime.now(),
|
||||
_endDate ?? AppTime.now(),
|
||||
_draftSchedules,
|
||||
ref.read(dutySchedulesProvider).valueOrNull ?? const [],
|
||||
ref.read(rotationConfigProvider).valueOrNull,
|
||||
);
|
||||
});
|
||||
@@ -1455,6 +1462,7 @@ class _ScheduleGeneratorPanelState
|
||||
_startDate ?? result.startTime,
|
||||
_endDate ?? result.endTime,
|
||||
_draftSchedules,
|
||||
ref.read(dutySchedulesProvider).valueOrNull ?? const [],
|
||||
ref.read(rotationConfigProvider).valueOrNull,
|
||||
);
|
||||
});
|
||||
@@ -2171,6 +2179,7 @@ class _ScheduleGeneratorPanelState
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
List<_DraftSchedule> drafts,
|
||||
List<DutySchedule> existing,
|
||||
RotationConfig? rotationConfig,
|
||||
) {
|
||||
final warnings = <String>[];
|
||||
@@ -2187,15 +2196,27 @@ class _ScheduleGeneratorPanelState
|
||||
required.addAll({'am', 'pm'});
|
||||
}
|
||||
|
||||
final available = drafts
|
||||
.where(
|
||||
(draft) =>
|
||||
draft.startTime.year == day.year &&
|
||||
draft.startTime.month == day.month &&
|
||||
draft.startTime.day == day.day,
|
||||
)
|
||||
.map((draft) => _normalizeShiftType(draft.shiftType))
|
||||
.toSet();
|
||||
final available = <String>{
|
||||
...drafts
|
||||
.where(
|
||||
(draft) =>
|
||||
draft.startTime.year == day.year &&
|
||||
draft.startTime.month == day.month &&
|
||||
draft.startTime.day == day.day,
|
||||
)
|
||||
.map((draft) => _coverageShiftType(draft.shiftType)),
|
||||
// Shifts already committed for this day count as covered, so
|
||||
// regenerating a partially/fully scheduled range doesn't report
|
||||
// false "uncovered" gaps that contradict the Schedule tab.
|
||||
...existing
|
||||
.where(
|
||||
(schedule) =>
|
||||
schedule.startTime.year == day.year &&
|
||||
schedule.startTime.month == day.month &&
|
||||
schedule.startTime.day == day.day,
|
||||
)
|
||||
.map((schedule) => _coverageShiftType(schedule.shiftType)),
|
||||
};
|
||||
|
||||
for (final shift in required) {
|
||||
if (!available.contains(shift)) {
|
||||
@@ -2219,6 +2240,17 @@ class _ScheduleGeneratorPanelState
|
||||
return value;
|
||||
}
|
||||
|
||||
// The uncovered-shifts check requires a generic 'on_call' each day, but the
|
||||
// generator emits the weekend variants 'on_call_saturday'/'on_call_sunday'.
|
||||
// Fold those to 'on_call' for coverage only (not for templates/generation),
|
||||
// otherwise every Saturday and Sunday reports a false "missing On Call".
|
||||
String _coverageShiftType(String value) {
|
||||
if (value == 'on_call_saturday' || value == 'on_call_sunday') {
|
||||
return 'on_call';
|
||||
}
|
||||
return _normalizeShiftType(value);
|
||||
}
|
||||
|
||||
String _shiftLabel(String value, RotationConfig? rotationConfig) {
|
||||
final configured = rotationConfig?.shiftTypes.firstWhere(
|
||||
(s) => s.id == value,
|
||||
|
||||
Reference in New Issue
Block a user