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
@@ -6,7 +6,7 @@ import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'routing/app_router.dart';
import 'models/profile.dart';
import 'models/profile.model.dart';
import 'providers/connectivity_provider.dart';
import 'providers/profile_provider.dart';
import 'services/background_location_service.dart';
+10 -9
View File
@@ -249,8 +249,7 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// The flag optionally hides annoying WASM warnings in your Chrome dev console
pdfrxFlutterInitialize(dismissPdfiumWasmWarnings: true);
pdfrxFlutterInitialize();
// initialize Firebase before anything that uses messaging
try {
@@ -768,6 +767,10 @@ class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
@override
Widget build(BuildContext context) {
// Web has no update check. Skip the wrapper MaterialApp there: its plain
// Navigator gets the browser URL as its initial route and logs "Could not
// navigate to initial route" on every deep link.
if (kIsWeb) return const NotificationBridge(child: TasqApp());
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
@@ -791,14 +794,12 @@ class _UpdateCheckWrapperState extends State<UpdateCheckWrapper> {
),
);
},
child: kIsWeb
child: _done
? const NotificationBridge(child: TasqApp())
: (_done
? const NotificationBridge(child: TasqApp())
: UpdateCheckingScreen(
checkForUpdates: _checkForUpdates,
onCompleted: _handleUpdateComplete,
)),
: UpdateCheckingScreen(
checkForUpdates: _checkForUpdates,
onCompleted: _handleUpdateComplete,
),
),
);
}
-1
View File
@@ -1 +0,0 @@
export 'announcement.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'announcement_comment.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'attendance_log.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'chat_message.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'duty_schedule.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'it_service_request.model.dart';
@@ -1 +0,0 @@
export 'it_service_request_action.model.dart';
@@ -1 +0,0 @@
export 'it_service_request_activity_log.model.dart';
@@ -1 +0,0 @@
export 'it_service_request_assignment.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'leave_of_absence.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'notification_item.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'office.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'pass_slip.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'profile.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'service.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'swap_request.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'task.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'task_activity_log.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'team.model.dart';
-1
View File
@@ -1 +0,0 @@
export 'ticket.model.dart';
+33 -31
View File
@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/announcement.dart';
import '../models/announcement_comment.dart';
import '../models/announcement.model.dart';
import '../models/announcement_comment.model.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
@@ -273,35 +273,37 @@ final announcementCommentsProvider =
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
}
final wrapper = StreamRecoveryWrapper<AnnouncementComment>(
stream: client
.from('announcement_comments')
.stream(primaryKey: ['id'])
.eq('announcement_id', announcementId)
.order('created_at'),
onPollData: () async {
final data = await client
.from('announcement_comments')
.select()
.eq('announcement_id', announcementId)
.order('created_at');
return data.map(AnnouncementComment.fromMap).toList();
},
fromMap: AnnouncementComment.fromMap,
channelName: 'announcement_comments_$announcementId',
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<AnnouncementComment>();
final filtered = all
.where((c) => c.announcementId == announcementId)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>(
rows,
tag: 'announcement_comments'),
);
final wrapper = StreamRecoveryWrapper<AnnouncementComment>(
stream: client
.from('announcement_comments')
.stream(primaryKey: ['id'])
.eq('announcement_id', announcementId)
.order('created_at', ascending: true),
onPollData: () async {
final data = await client
.from('announcement_comments')
.select()
.eq('announcement_id', announcementId)
.order('created_at', ascending: true);
return data.map(AnnouncementComment.fromMap).toList();
},
fromMap: AnnouncementComment.fromMap,
channelName: 'announcement_comments_$announcementId',
onStatusChanged: ref
.read(realtimeControllerProvider)
.handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<AnnouncementComment>();
final filtered =
all.where((c) => c.announcementId == announcementId).toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) => mirrorBatchToBrick<AnnouncementComment>(
rows,
tag: 'announcement_comments',
),
);
ref.onDispose(wrapper.dispose);
return wrapper.stream.map((result) => applyPending(result.data));
+1 -1
View File
@@ -8,7 +8,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/attendance_log.dart';
import '../models/attendance_log.model.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
+7 -8
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/chat_message.dart';
import '../models/chat_message.model.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
@@ -42,21 +42,22 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
byId.putIfAbsent(p.id, () => p);
}
return byId.values.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
// Newest-first everywhere: the swap chat renders with ListView(reverse: true).
final wrapper = StreamRecoveryWrapper<ChatMessage>(
stream: client
.from('chat_messages')
.stream(primaryKey: ['id'])
.eq('thread_id', threadId)
.order('created_at'),
.order('created_at', ascending: false),
onPollData: () async {
final data = await client
.from('chat_messages')
.select()
.eq('thread_id', threadId)
.order('created_at');
.order('created_at', ascending: false);
return data.map(ChatMessage.fromMap).toList();
},
fromMap: ChatMessage.fromMap,
@@ -64,10 +65,8 @@ final chatMessagesProvider = StreamProvider.family<List<ChatMessage>, String>((
onStatusChanged: ref.read(realtimeControllerProvider).handleChannelStatus,
onOfflineData: () async {
final all = await cachedListFromBrick<ChatMessage>();
final filtered = all
.where((m) => m.threadId == threadId)
.toList()
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
final filtered = all.where((m) => m.threadId == threadId).toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return applyPending(filtered);
},
onCacheMirror: (rows) =>
@@ -9,10 +9,10 @@ import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.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/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 '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/leave_of_absence.dart';
import '../models/leave_of_absence.model.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
@@ -8,7 +8,7 @@ import '../supabase_provider.dart';
final networkDevicesProvider = FutureProvider<List<NetworkDevice>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_devices').select().order('name');
final rows = await client.from('network_devices').select().order('name', ascending: true);
return (rows as List)
.map((r) => NetworkDevice.fromMap(r as Map<String, dynamic>))
.toList();
@@ -16,7 +16,7 @@ final networkDevicesProvider = FutureProvider<List<NetworkDevice>>((ref) async {
final networkPortsProvider = FutureProvider<List<NetworkPort>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_ports').select().order('port_number');
final rows = await client.from('network_ports').select().order('port_number', ascending: true);
return (rows as List)
.map((r) => NetworkPort.fromMap(r as Map<String, dynamic>))
.toList();
@@ -32,7 +32,7 @@ final networkLinksProvider = FutureProvider<List<NetworkLink>>((ref) async {
final networkVlansProvider = FutureProvider<List<NetworkVlan>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_vlans').select().order('vlan_id');
final rows = await client.from('network_vlans').select().order('vlan_id', ascending: true);
return (rows as List)
.map((r) => NetworkVlan.fromMap(r as Map<String, dynamic>))
.toList();
@@ -57,7 +57,7 @@ final networkPortsByDeviceProvider =
.from('network_ports')
.select()
.eq('device_id', deviceId)
.order('port_number');
.order('port_number', ascending: true);
return (rows as List)
.map((r) => NetworkPort.fromMap(r as Map<String, dynamic>))
.toList();
@@ -6,7 +6,7 @@ import '../supabase_provider.dart';
final networkSitesProvider = FutureProvider<List<NetworkSite>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_sites').select().order('name');
final rows = await client.from('network_sites').select().order('name', ascending: true);
return (rows as List)
.map((r) => NetworkSite.fromMap(r as Map<String, dynamic>))
.toList();
@@ -14,7 +14,7 @@ final networkSitesProvider = FutureProvider<List<NetworkSite>>((ref) async {
final networkLocationsProvider = FutureProvider<List<NetworkLocation>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('network_locations').select().order('name');
final rows = await client.from('network_locations').select().order('name', ascending: true);
return (rows as List)
.map((r) => NetworkLocation.fromMap(r as Map<String, dynamic>))
.toList();
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../utils/device_id.dart';
import '../brick/cache_helpers.dart';
import '../models/notification_item.dart';
import '../models/notification_item.model.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../models/pass_slip.dart';
import '../models/pass_slip.model.dart';
import '../utils/app_time.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'connectivity_provider.dart';
+6 -3
View File
@@ -9,7 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart';
import '../models/profile.dart';
import '../models/profile.model.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import 'auth_provider.dart';
import 'connectivity_provider.dart';
@@ -165,9 +165,12 @@ final profilesProvider = StreamProvider<List<Profile>>((ref) {
stream: client
.from('profiles')
.stream(primaryKey: ['id'])
.order('full_name'),
.order('full_name', ascending: true),
onPollData: () async {
final data = await client.from('profiles').select().order('full_name');
final data = await client
.from('profiles')
.select()
.order('full_name', ascending: true);
return data.map(Profile.fromMap).toList();
},
fromMap: Profile.fromMap,
+13 -4
View File
@@ -1,7 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../brick/cache_helpers.dart';
import '../models/service.dart';
import '../models/service.model.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
import 'realtime_controller.dart';
@@ -10,9 +10,15 @@ final servicesProvider = StreamProvider<List<Service>>((ref) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<Service>(
stream: client.from('services').stream(primaryKey: ['id']).order('name'),
stream: client
.from('services')
.stream(primaryKey: ['id'])
.order('name', ascending: true),
onPollData: () async {
final data = await client.from('services').select().order('name');
final data = await client
.from('services')
.select()
.order('name', ascending: true);
return data.map(Service.fromMap).toList();
},
fromMap: Service.fromMap,
@@ -33,7 +39,10 @@ final servicesProvider = StreamProvider<List<Service>>((ref) {
final servicesOnceProvider = FutureProvider<List<Service>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('services').select().order('name');
final rows = await client
.from('services')
.select()
.order('name', ascending: true);
return (rows as List<dynamic>)
.map((r) => Service.fromMap(r as Map<String, dynamic>))
.toList();
+4
View File
@@ -1,3 +1,7 @@
// The constructor deliberately exposes public named params (onPollData, …) that
// initialize private fields (_onPollData, …); the initializing-formal "fix"
// would rename the params to private and break all call sites.
// ignore_for_file: prefer_initializing_formals
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
+2 -2
View File
@@ -9,8 +9,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart';
import '../brick/repository.dart';
import '../models/task.dart';
import '../models/task_activity_log.dart';
import '../models/task.model.dart';
import '../models/task_activity_log.model.dart';
import 'connectivity_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
+9 -3
View File
@@ -2,7 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../brick/cache_helpers.dart';
import '../brick/cache_warmer.dart';
import '../models/team.dart';
import '../models/team.model.dart';
import '../models/team_member.dart';
import 'realtime_controller.dart';
import 'stream_recovery.dart';
@@ -13,9 +13,15 @@ final teamsProvider = StreamProvider<List<Team>>((ref) {
final client = ref.watch(supabaseClientProvider);
final wrapper = StreamRecoveryWrapper<Team>(
stream: client.from('teams').stream(primaryKey: ['id']).order('name'),
stream: client
.from('teams')
.stream(primaryKey: ['id'])
.order('name', ascending: true),
onPollData: () async {
final data = await client.from('teams').select().order('name');
final data = await client
.from('teams')
.select()
.order('name', ascending: true);
return data.map(Team.fromMap).toList();
},
fromMap: Team.fromMap,
+14 -5
View File
@@ -10,8 +10,8 @@ import 'package:uuid/uuid.dart';
import '../brick/cache_helpers.dart';
import '../brick/repository.dart';
import '../utils/snackbar.dart' show isOfflineSaveError;
import '../models/office.dart';
import '../models/ticket.dart';
import '../models/office.model.dart';
import '../models/ticket.model.dart';
import '../models/ticket_message.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
@@ -39,9 +39,15 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
}
final wrapper = StreamRecoveryWrapper<Office>(
stream: client.from('offices').stream(primaryKey: ['id']).order('name'),
stream: client
.from('offices')
.stream(primaryKey: ['id'])
.order('name', ascending: true),
onPollData: () async {
final data = await client.from('offices').select().order('name');
final data = await client
.from('offices')
.select()
.order('name', ascending: true);
return data.map(Office.fromMap).toList();
},
fromMap: Office.fromMap,
@@ -94,7 +100,10 @@ final officesProvider = StreamProvider<List<Office>>((ref) {
final officesOnceProvider = FutureProvider<List<Office>>((ref) async {
final client = ref.watch(supabaseClientProvider);
final rows = await client.from('offices').select().order('name');
final rows = await client
.from('offices')
.select()
.order('name', ascending: true);
return (rows as List<dynamic>)
.map((row) => Office.fromMap(row as Map<String, dynamic>))
.toList();
+2 -2
View File
@@ -52,9 +52,9 @@ final userOfficesProvider = StreamProvider<List<UserOffice>>((ref) {
stream: client
.from('user_offices')
.stream(primaryKey: ['user_id', 'office_id'])
.order('created_at'),
.order('created_at', ascending: true),
onPollData: () async {
final data = await client.from('user_offices').select().order('created_at');
final data = await client.from('user_offices').select().order('created_at', ascending: true);
return data.map(UserOffice.fromMap).toList();
},
fromMap: UserOffice.fromMap,
+15 -11
View File
@@ -7,8 +7,8 @@ import 'package:supabase_flutter/supabase_flutter.dart';
import '../brick/cache_helpers.dart';
import '../models/app_settings.dart';
import '../models/duty_schedule.dart';
import '../models/swap_request.dart';
import '../models/duty_schedule.model.dart';
import '../models/swap_request.model.dart';
import 'profile_provider.dart';
import 'supabase_provider.dart';
import 'stream_recovery.dart';
@@ -65,12 +65,12 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
stream: client
.from('duty_schedules')
.stream(primaryKey: ['id'])
.order('start_time'),
.order('start_time', ascending: true),
onPollData: () async {
final data = await client
.from('duty_schedules')
.select()
.order('start_time');
.order('start_time', ascending: true);
return data.map(DutySchedule.fromMap).toList();
},
fromMap: DutySchedule.fromMap,
@@ -89,13 +89,16 @@ final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
// Immediate poll so any changes that happened while this provider was
// not alive (e.g. a swap was accepted on another device) are reflected
// right away — before the 3-second periodic timer fires.
// right away — before the periodic timer fires.
wrapper.pollNow();
// Periodic safety-net: keep polling every 3 s so that ownership changes
// Periodic safety-net: keep polling so that ownership changes
// (swap accepted → user_id updated on duty_schedules) are always picked
// up even if Supabase Realtime misses the event.
final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
// ponytail: was 3 s, i.e. every client re-downloaded the whole table ~20x a
// minute. Realtime is the primary path; 60 s bounds how stale a missed swap
// can get. Tighten only if swaps need faster convergence.
final dutyRefreshTimer = Timer.periodic(const Duration(seconds: 60), (_) {
wrapper.pollNow();
});
ref.onDispose(dutyRefreshTimer.cancel);
@@ -135,7 +138,7 @@ final dutySchedulesForUserProvider =
.eq('user_id', userId)
/* exclude past schedules by ensuring the shift has not ended */
.gte('end_time', nowIso)
.order('start_time')
.order('start_time', ascending: true)
as List<dynamic>;
return rows
.map((r) => DutySchedule.fromMap(r as Map<String, dynamic>))
@@ -201,13 +204,14 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
// Immediate poll: fetch fresh data right away so any status changes that
// happened while this provider was not alive are reflected instantly,
// before the 3-second periodic timer fires for the first time.
// before the periodic timer fires for the first time.
wrapper.pollNow();
// Periodic safety-net: keep polling every 3 s to catch any status changes
// Periodic safety-net: keep polling to catch any status changes
// that Supabase Realtime may have missed (e.g. when the swap_requests table
// is not yet in the supabase_realtime publication).
final refreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
// ponytail: was 3 s (see dutySchedulesProvider); 60 s safety net.
final refreshTimer = Timer.periodic(const Duration(seconds: 60), (_) {
wrapper.pollNow();
});
ref.onDispose(refreshTimer.cancel);
+34 -93
View File
@@ -41,6 +41,9 @@ import '../theme/m3_motion.dart';
import '../utils/navigation.dart';
Page<dynamic> Function(BuildContext, GoRouterState) _page(Widget child) =>
(context, state) => M3SharedAxisPage(key: state.pageKey, child: child);
String _defaultRouteForRole(String? role) {
switch (role) {
case 'it_staff':
@@ -100,14 +103,18 @@ final appRouterProvider = Provider<GoRouter>((ref) {
return _defaultRouteForRole(role);
}
// Deferred post-login redirect: profile loaded after the initial redirect
// (which fell back to /dashboard because role was null). Only fires once
// per sign-in and only when the user is still on /dashboard.
if (isSignedIn &&
notifier._needsRoleRedirect &&
role != null &&
state.matchedLocation == '/dashboard') {
// (which fell back to /dashboard because role was null). Consumed once
// per sign-in; only redirects when the user is still on /dashboard.
if (isSignedIn && notifier._needsRoleRedirect && role != null) {
notifier._needsRoleRedirect = false;
return _defaultRouteForRole(role);
if (state.matchedLocation == '/dashboard') {
return _defaultRouteForRole(role);
}
}
// Role unknown while the profile loads: hold the deep link instead of
// bouncing it. RouterNotifier re-runs this once the profile arrives.
if (isSignedIn && profileAsync.isLoading && role == null) {
return null;
}
if (isAdminRoute && !isAdmin) {
return '/tickets';
@@ -145,31 +152,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
routes: [
GoRoute(
path: '/settings/teams',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TeamsScreen(),
),
pageBuilder: _page(const TeamsScreen()),
),
GoRoute(
path: '/settings/app-update',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const AppUpdateScreen(),
),
pageBuilder: _page(const AppUpdateScreen()),
),
GoRoute(
path: '/dashboard',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const DashboardScreen(),
),
pageBuilder: _page(const DashboardScreen()),
),
GoRoute(
path: '/tickets',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TicketsListScreen(),
),
pageBuilder: _page(const TicketsListScreen()),
routes: [
GoRoute(
path: ':id',
@@ -184,10 +179,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/tasks',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const TasksListScreen(),
),
pageBuilder: _page(const TasksListScreen()),
routes: [
GoRoute(
path: ':id',
@@ -202,10 +194,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/it-service-requests',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ItServiceRequestsListScreen(),
),
pageBuilder: _page(const ItServiceRequestsListScreen()),
routes: [
GoRoute(
path: ':id',
@@ -220,80 +209,44 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/announcements',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const AnnouncementsScreen(),
),
pageBuilder: _page(const AnnouncementsScreen()),
),
GoRoute(
path: '/workforce',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const WorkforceScreen(),
),
pageBuilder: _page(const WorkforceScreen()),
),
GoRoute(
path: '/attendance',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const AttendanceScreen(),
),
pageBuilder: _page(const AttendanceScreen()),
),
GoRoute(
path: '/whereabouts',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const WhereaboutsScreen(),
),
),
GoRoute(
path: '/reports',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ReportsScreen(),
),
pageBuilder: _page(const WhereaboutsScreen()),
),
GoRoute(path: '/reports', pageBuilder: _page(const ReportsScreen())),
GoRoute(
path: '/settings/users',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const UserManagementScreen(),
),
pageBuilder: _page(const UserManagementScreen()),
),
GoRoute(
path: '/settings/offices',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const OfficesScreen(),
),
pageBuilder: _page(const OfficesScreen()),
),
GoRoute(
path: '/settings/geofence-test',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const GeofenceTestScreen(),
),
pageBuilder: _page(const GeofenceTestScreen()),
),
GoRoute(
path: '/settings/permissions',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const PermissionsScreen(),
),
pageBuilder: _page(const PermissionsScreen()),
),
GoRoute(
path: '/network-map',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const NetworkMapOverviewScreen(),
),
pageBuilder: _page(const NetworkMapOverviewScreen()),
routes: [
GoRoute(
path: 'import',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const NetworkMapImportScreen(),
),
pageBuilder: _page(const NetworkMapImportScreen()),
routes: [
GoRoute(
path: ':importId/review',
@@ -308,10 +261,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: 'vlans',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const NetworkMapVlanScreen(),
),
pageBuilder: _page(const NetworkMapVlanScreen()),
),
GoRoute(
path: 'site/:siteId',
@@ -357,18 +307,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/notifications',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const NotificationsScreen(),
),
),
GoRoute(
path: '/profile',
pageBuilder: (context, state) => M3SharedAxisPage(
key: state.pageKey,
child: const ProfileScreen(),
),
pageBuilder: _page(const NotificationsScreen()),
),
GoRoute(path: '/profile', pageBuilder: _page(const ProfileScreen())),
],
),
],
+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,
-144
View File
@@ -1,144 +0,0 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:http/http.dart' as http;
class GeminiService {
static final GeminiService _instance = GeminiService._internal();
late final String _apiKey;
/// Cache of valid model IDs (flash/lite, supporting generateContent).
List<String> _validModels = [];
factory GeminiService() => _instance;
GeminiService._internal() {
final apiKey = dotenv.env['GEMINI_API_KEY'];
if (apiKey == null || apiKey.isEmpty) {
throw Exception('GEMINI_API_KEY not found in .env file');
}
_apiKey = apiKey;
}
// ---------------------------------------------------------------------------
// Model discovery
// ---------------------------------------------------------------------------
/// Queries the Gemini REST API for available models and caches those that
/// - support `generateContent`, AND
/// - contain "flash" or "lite" in their name (free-tier / fast models).
///
/// Returns a stable cached list on subsequent calls.
Future<List<String>> _getValidModels() async {
if (_validModels.isNotEmpty) return _validModels;
try {
final uri = Uri.parse(
'https://generativelanguage.googleapis.com/v1beta/models?key=$_apiKey',
);
final response = await http.get(uri);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final rawModels = (data['models'] as List<dynamic>?) ?? [];
final discovered = <String>[];
for (final m in rawModels) {
final fullName = (m['name'] as String? ?? '');
final lower = fullName.toLowerCase();
final methods =
(m['supportedGenerationMethods'] as List<dynamic>?) ?? [];
if (methods.contains('generateContent') &&
(lower.contains('flash') || lower.contains('lite'))) {
// Strip the "models/" prefix so it can be passed directly to
// GenerativeModel(model: ...).
final id = fullName.startsWith('models/')
? fullName.substring('models/'.length)
: fullName;
discovered.add(id);
}
}
// Sort descending so newer/more-capable models are tried first.
discovered.sort((a, b) => b.compareTo(a));
_validModels = discovered;
}
} catch (_) {
// Fall back to hard-coded list of known free-tier models below.
}
// If discovery failed or returned nothing, use safe known fallbacks.
if (_validModels.isEmpty) {
_validModels = [
'gemini-2.5-flash-lite',
'gemini-2.5-flash',
'gemini-2.0-flash',
'gemini-1.5-flash',
];
}
return _validModels;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Fixes spelling/grammar, improves clarity, and translates [text] to
/// professional English.
///
/// Provide a custom [promptInstruction] to give Gemini field-specific
/// context (subject, description, action-taken). If omitted a sensible
/// default is used.
///
/// Automatically retries with the next available model on 429 / quota
/// errors to minimise wasted quota calls.
Future<String> enhanceText(String text, {String? promptInstruction}) async {
if (text.trim().isEmpty) return text;
final instruction =
promptInstruction ??
'Fix spelling and grammar, improve clarity, and translate to '
'professional English. Return ONLY the improved text, no explanations:';
final prompt = '$instruction\n\n"$text"';
return _generateWithRetry(prompt, fallback: text);
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Sends [prompt] to Gemini, retrying across all valid models on 429 errors.
/// Returns the model response on success, or throws if all models fail.
Future<String> _generateWithRetry(
String prompt, {
required String fallback,
}) async {
final models = await _getValidModels();
for (int i = 0; i < models.length; i++) {
try {
final model = GenerativeModel(model: models[i], apiKey: _apiKey);
final response = await model.generateContent([Content.text(prompt)]);
return response.text ?? fallback;
} catch (e) {
final msg = e.toString().toLowerCase();
final is429 =
msg.contains('429') ||
msg.contains('quota') ||
msg.contains('resource_exhausted');
if (!is429 || i == models.length - 1) {
// Non-quota error or last model — give up.
throw Exception('Gemini request failed: $e');
}
// Quota hit — try the next model.
}
}
// Should not reach here, but safety fallback.
throw Exception('All Gemini models exhausted');
}
}
+41 -26
View File
@@ -2,10 +2,28 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import '../models/notification_item.dart';
import '../models/notification_item.model.dart';
import '../providers/notifications_provider.dart';
import '../providers/notification_navigation_provider.dart';
import '../routing/app_router.dart';
import '../utils/snackbar.dart';
/// The newest unread item in [next] that wasn't in [prev], or null.
///
/// Returns null for the first snapshot ([prev] == null) so the existing inbox
/// doesn't raise a banner on startup. [next] is sorted newest-first.
@visibleForTesting
NotificationItem? newestArrival(
List<NotificationItem>? prev,
List<NotificationItem> next,
) {
if (prev == null) return null;
final seen = {for (final n in prev) n.id};
for (final n in next) {
if (n.isUnread && !seen.contains(n.id)) return n;
}
return null;
}
/// Wraps the app and installs both a Supabase realtime listener and the
/// FCM handlers described in the frontend design.
@@ -24,8 +42,8 @@ class NotificationBridge extends ConsumerStatefulWidget {
class _NotificationBridgeState extends ConsumerState<NotificationBridge>
with WidgetsBindingObserver {
// store previous notifications to diff
List<NotificationItem> _prevList = [];
// Last loaded snapshot to diff against; null until the inbox first loads.
List<NotificationItem>? _prevList;
@override
void initState() {
@@ -92,24 +110,25 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
};
void _showBanner(String type, NotificationItem item) {
// Use a post-frame callback so that the ScaffoldMessenger from
// MaterialApp is available in the element tree.
// This widget sits above TasqApp's MaterialApp.router, so
// ScaffoldMessenger.of(context) would resolve to a messenger with no
// Scaffold. Use the app-level messenger key instead.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_bannerLabel(type)),
action: SnackBarAction(
label: 'View',
onPressed: () =>
_goToDetail(taskId: item.taskId, ticketId: item.ticketId),
scaffoldMessengerKey.currentState?.showSnackBar(
SnackBar(
content: Text(_bannerLabel(type)),
action: SnackBarAction(
label: 'View',
onPressed: () => _goToDetail(
route: item.announcementId != null ? '/announcements' : null,
taskId: item.taskId,
ticketId: item.ticketId,
itServiceRequestId: item.itServiceRequestId,
),
),
);
} catch (_) {
// ScaffoldMessenger not available yet (before first frame) — ignore.
}
),
);
});
}
@@ -163,16 +182,12 @@ class _NotificationBridgeState extends ConsumerState<NotificationBridge>
previous,
next,
) {
final prevList = _prevList;
final nextList = next.maybeWhen(
data: (d) => d,
orElse: () => <NotificationItem>[],
);
if (nextList.length > prevList.length) {
final newItem = nextList.last;
_showBanner(newItem.type, newItem);
}
// Loading/error emissions (e.g. invalidate on resume) keep the snapshot.
final nextList = next.valueOrNull;
if (nextList == null) return;
final item = newestArrival(_prevList, nextList);
_prevList = nextList;
if (item != null) _showBanner(item.type, item);
});
// Listen for pending navigation from local-notification taps
-41
View File
@@ -871,44 +871,3 @@ class _M3BounceIconState extends State<M3BounceIcon>
return ScaleTransition(scale: _entrance, child: badge);
}
}
// ═══════════════════════════════════════════════════════════════
// M3AnimatedCounter — smooth number animation
// ═══════════════════════════════════════════════════════════════
/// Animates an integer value from its previous value to [value] using a
/// [TweenAnimationBuilder], producing a smooth counting effect on metric
/// cards and dashboard KPIs.
///
/// ```dart
/// M3AnimatedCounter(
/// value: totalTasks,
/// style: theme.textTheme.headlineMedium,
/// )
/// ```
class M3AnimatedCounter extends StatelessWidget {
const M3AnimatedCounter({
super.key,
required this.value,
this.style,
this.duration = M3Motion.standard,
});
final int value;
final TextStyle? style;
final Duration duration;
@override
Widget build(BuildContext context) {
if (m3ReducedMotion(context)) {
return Text(value.toString(), style: style);
}
return TweenAnimationBuilder<int>(
tween: IntTween(begin: 0, end: value),
duration: duration,
curve: M3Motion.emphasizedEnter,
builder: (_, v, _) => Text(v.toString(), style: style),
);
}
}
+10
View File
@@ -29,6 +29,16 @@ class AppTime {
return toAppTime(DateTime.parse(value));
}
/// "Just now", "5m ago", "3h ago", "2d ago", then a short date after a week.
static String relative(DateTime value) {
final diff = now().difference(value);
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 formatDate(value);
}
/// Converts a [DateTime] into a human-readable short date string.
///
/// Example: **Jan 05, 2025**. This matches the format previously used by
+17
View File
@@ -0,0 +1,17 @@
import 'dart:async';
/// Runs an action after [duration] of inactivity, cancelling any pending
/// run each time [run] is called again.
class Debouncer {
Debouncer(this.duration);
final Duration duration;
Timer? _timer;
void run(void Function() action) {
_timer?.cancel();
_timer = Timer(duration, action);
}
void cancel() => _timer?.cancel();
}
+3 -1
View File
@@ -1,12 +1,14 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:safe_device/safe_device.dart';
/// Returns true if the device appears compromised for attendance purposes:
/// rooted (Android) / jailbroken (iOS), or developer mode enabled.
/// Returns false on any error so a detection failure never blocks a valid user.
Future<bool> isDeviceCompromised() async {
if (!Platform.isAndroid && !Platform.isIOS) return false;
// dart:io Platform throws on web, so check kIsWeb first.
if (kIsWeb || (!Platform.isAndroid && !Platform.isIOS)) return false;
try {
final isJailBroken = await SafeDevice.isJailBroken;
final isDeveloperMode = await SafeDevice.isDevelopmentModeEnable;
+16 -2
View File
@@ -29,6 +29,20 @@ ContentType _mapSnackType(SnackType t) {
}
}
/// Reduces exception dumps that callers interpolate into messages (e.g.
/// `'Error: \$e'` → `Error: PostgrestException(message: X, code: …)`) to
/// just the human-readable `X`, so users never see raw exception syntax.
@visibleForTesting
String humanizeErrorText(String text) => text
.replaceAllMapped(
RegExp(
r'\w*Exception\(message: (.*?)(?:, (?:statusCode|code|details|hint|error):.*)?\)$',
dotAll: true,
),
(m) => m[1]!,
)
.replaceAll(RegExp(r'\bException: '), '');
/// Core function used by all of the convenience helpers below.
void showAwesomeSnackBar(
BuildContext context, {
@@ -49,7 +63,7 @@ void showAwesomeSnackBar(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: AwesomeSnackbarContent(
title: title,
message: message,
message: humanizeErrorText(message),
contentType: _mapSnackType(snackType),
),
),
@@ -176,7 +190,7 @@ void showAwesomeSnackBarGlobal({
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: AwesomeSnackbarContent(
title: title,
message: message,
message: humanizeErrorText(message),
contentType: _mapSnackType(snackType),
),
),
+1 -1
View File
@@ -1,4 +1,4 @@
import '../models/task_activity_log.dart';
import '../models/task_activity_log.model.dart';
/// Finds the canonical execution start time for a task.
///
+14 -8
View File
@@ -158,7 +158,11 @@ class _AppNavigationRailState extends State<AppNavigationRail> {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final currentIndex = _currentIndex(widget.location, widget.items);
// Exact match only: routes outside the rail (profile, notifications)
// highlight nothing instead of falling back to Dashboard.
final currentIndex = widget.items.indexWhere(
(item) => _isSelected(widget.location, item.route),
);
// M3 Expressive: tonal surface container instead of a hard border divider.
// Custom scrollable layout replaces NavigationRail so items are reachable
@@ -481,15 +485,17 @@ List<NavSection> _buildSections(String role) {
icon: Icons.groups_outlined,
selectedIcon: Icons.groups,
),
NavItem(
label: 'Reports',
route: '/reports',
icon: Icons.analytics_outlined,
selectedIcon: Icons.analytics,
),
if (!isStandard)
NavItem(
label: 'Reports',
route: '/reports',
icon: Icons.analytics_outlined,
selectedIcon: Icons.analytics,
),
];
if (role == 'admin' || role == 'programmer' || role == 'dispatcher') {
// Must match the /settings guard in app_router.dart (admin/programmer).
if (role == 'admin' || role == 'programmer') {
return [
NavSection(label: 'Operations', items: mainItems),
NavSection(
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import '../models/office.dart';
import '../models/office.model.dart';
import '../theme/m3_motion.dart';
import 'app_breakpoints.dart';
+9 -7
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/profile.dart';
import '../models/profile.model.dart';
import '../models/task_assignment.dart';
import '../providers/profile_provider.dart';
import '../providers/tasks_provider.dart';
@@ -68,13 +68,15 @@ class TaskAssignmentSection extends ConsumerWidget {
children: [
Row(
children: [
Text(
'Assigned IT Staff',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
Expanded(
child: Text(
'Assigned IT Staff',
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
const Spacer(),
if (canAssign)
TextButton.icon(
onPressed: () => _showAssignmentDialog(
+5 -1
View File
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import '../theme/app_typography.dart';
import '../theme/app_surfaces.dart';
import '../theme/m3_motion.dart';
import 'app_breakpoints.dart';
import 'mono_text.dart';
/// A column configuration for the [TasQAdaptiveList] desktop table view.
@@ -359,7 +360,10 @@ class TasQAdaptiveList<T> extends StatelessWidget {
// On medium screens (< 1200px) hide columns that are marked hideOnMedium,
// preventing the horizontal scrollbar from appearing on tablet viewports.
final isMediumScreen = constraints.maxWidth < 1200;
// Measure the screen, not this list: after the nav rail and page padding
// the list is < 1200px on most desktops, which hid e.g. Tasks' Status.
final isMediumScreen =
MediaQuery.sizeOf(context).width < AppBreakpoints.desktop;
final visibleColumns = isMediumScreen
? columns.where((c) => !c.hideOnMedium).toList()
: columns;
+2 -3
View File
@@ -5,6 +5,7 @@ import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../models/app_version.dart';
import '../services/app_update_service.dart';
import '../utils/snackbar.dart';
/// A reusable dialog that can render both flexible and forced updates and
/// report download progress. Callers should wrap this with `showDialog` and
@@ -47,9 +48,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
_failed = true;
_downloading = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download failed: $err')));
showErrorSnackBar(context, 'Download failed: $err');
}
}