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