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:
2026-09-26 11:52:16 +08:00
parent e49b52949c
commit 3cb980b629
125 changed files with 3420 additions and 2853 deletions
+1 -1
View File
@@ -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+'));
+11 -18
View File
@@ -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');
}
}
+6 -3
View File
@@ -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
+6
View File
@@ -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
+31 -43
View File
@@ -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';
+3 -4
View File
@@ -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 {
+2 -3
View File
@@ -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);
+1 -1
View File
@@ -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';
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -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(
+8 -13
View File
@@ -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});
+3 -3
View File
@@ -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';
+41 -16
View File
@@ -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 = [
+45 -13
View File
@@ -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,