From d87b5e73d79a39e92dcf1c3e3219aa813d0807a1 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sun, 8 Mar 2026 12:23:28 +0800 Subject: [PATCH] Team Color, image compression for attendance verification, improved wherebouts --- lib/models/team.dart | 3 + lib/screens/dashboard/dashboard_screen.dart | 203 ++++++-- lib/screens/teams/teams_screen.dart | 105 ++++ .../whereabouts/whereabouts_screen.dart | 483 ++++++++++++------ lib/services/background_location_service.dart | 3 + lib/services/image_compress_service.dart | 33 ++ lib/widgets/app_shell.dart | 16 +- lib/widgets/face_verification_overlay.dart | 51 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 48 ++ pubspec.yaml | 1 + 11 files changed, 757 insertions(+), 191 deletions(-) create mode 100644 lib/services/image_compress_service.dart diff --git a/lib/models/team.dart b/lib/models/team.dart index c79aac57..826a6b01 100644 --- a/lib/models/team.dart +++ b/lib/models/team.dart @@ -17,6 +17,7 @@ class Team { required this.leaderId, required this.officeIds, required this.createdAt, + this.color, }); final String id; @@ -24,6 +25,7 @@ class Team { final String leaderId; final List officeIds; final DateTime createdAt; + final String? color; factory Team.fromMap(Map map) { return Team( @@ -34,6 +36,7 @@ class Team { (map['office_ids'] as List?)?.map((e) => e.toString()).toList() ?? [], createdAt: DateTime.parse(map['created_at'] as String), + color: map['color'] as String?, ); } } diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index 978c0908..bda6a8fe 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -24,8 +24,13 @@ import '../../providers/tickets_provider.dart'; 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_member.dart'; import '../../widgets/responsive_body.dart'; import '../../widgets/reconnect_overlay.dart'; +import '../../widgets/profile_avatar.dart'; +import '../../widgets/app_breakpoints.dart'; import '../../providers/realtime_controller.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../../theme/app_surfaces.dart'; @@ -70,6 +75,8 @@ class StaffRowMetrics { required this.ticketsRespondedToday, required this.tasksClosedToday, required this.eventsHandledToday, + this.avatarUrl, + this.teamColor, }); final String userId; @@ -79,6 +86,8 @@ class StaffRowMetrics { final int ticketsRespondedToday; final int tasksClosedToday; final int eventsHandledToday; + final String? avatarUrl; + final String? teamColor; } final dashboardMetricsProvider = Provider>((ref) { @@ -94,6 +103,8 @@ final dashboardMetricsProvider = Provider>((ref) { final passSlipsAsync = ref.watch(passSlipsProvider); final isrAssignmentsAsync = ref.watch(itServiceRequestAssignmentsProvider); final isrAsync = ref.watch(itServiceRequestsProvider); + final teamsAsync = ref.watch(teamsProvider); + final teamMembersAsync = ref.watch(teamMembersProvider); final asyncValues = [ ticketsAsync, @@ -294,13 +305,29 @@ final dashboardMetricsProvider = Provider>((ref) { const triageWindow = Duration(minutes: 1); final triageCutoff = now.subtract(triageWindow); + // Pre-index team membership: map user → team color (hex string). + final teams = teamsAsync.valueOrNull ?? const []; + final teamMembers = teamMembersAsync.valueOrNull ?? const []; + final teamById = {for (final t in teams) t.id: t}; + final teamColorByUser = {}; + for (final m in teamMembers) { + final team = teamById[m.teamId]; + if (team != null) { + teamColorByUser[m.userId] = team.color; + } + } + // Pre-index schedules, logs, and positions by user for efficient lookup. + // Include schedules starting today AND overnight schedules from yesterday + // that span into today (e.g. on_call 11 PM – 7 AM). final todaySchedulesByUser = >{}; for (final s in schedules) { - // Exclude overtime schedules from regular duty tracking. - if (s.shiftType != 'overtime' && - !s.startTime.isBefore(startOfDay) && - s.startTime.isBefore(endOfDay)) { + if (s.shiftType == 'overtime') continue; + final startsToday = + !s.startTime.isBefore(startOfDay) && s.startTime.isBefore(endOfDay); + final overnightFromYesterday = + s.startTime.isBefore(startOfDay) && s.endTime.isAfter(startOfDay); + if (startsToday || overnightFromYesterday) { todaySchedulesByUser.putIfAbsent(s.userId, () => []).add(s); } } @@ -343,15 +370,22 @@ final dashboardMetricsProvider = Provider>((ref) { final onTask = staffOnTask.contains(staff.id); final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff); - // Whereabouts from live position. - final livePos = positionByUser[staff.id]; - final whereabouts = livePos != null - ? (livePos.inPremise ? 'In premise' : 'Outside premise') - : '\u2014'; - // Attendance-based status. final userSchedules = todaySchedulesByUser[staff.id] ?? const []; final userLogs = todayLogsByUser[staff.id] ?? const []; + + // Whereabouts from live position, with tracking-off inference. + final livePos = positionByUser[staff.id]; + final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut); + final String whereabouts; + if (livePos != null) { + whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise'; + } else if (!staff.allowTracking) { + // Tracking off — infer from active check-in (geofence validated). + whereabouts = hasActiveCheckIn ? 'In premise' : 'Tracking off'; + } else { + whereabouts = '\u2014'; + } final activeLog = userLogs.where((l) => !l.isCheckedOut).firstOrNull; final completedLogs = userLogs.where((l) => l.isCheckedOut).toList(); @@ -370,7 +404,25 @@ final dashboardMetricsProvider = Provider>((ref) { ? 'In triage' : 'Off duty'; } else { - final schedule = userSchedules.first; + // Pick the most relevant schedule: prefer one that is currently + // active (now is within start–end), then the nearest upcoming one, + // and finally the most recently ended one. + final activeSchedule = userSchedules + .where((s) => !now.isBefore(s.startTime) && now.isBefore(s.endTime)) + .firstOrNull; + + DutySchedule? upcomingSchedule; + if (activeSchedule == null) { + final upcoming = + userSchedules.where((s) => now.isBefore(s.startTime)).toList() + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + if (upcoming.isNotEmpty) upcomingSchedule = upcoming.first; + } + + final schedule = + activeSchedule ?? + upcomingSchedule ?? + userSchedules.reduce((a, b) => a.endTime.isAfter(b.endTime) ? a : b); final isShiftOver = !now.isBefore(schedule.endTime); final isFullDay = schedule.endTime.difference(schedule.startTime).inHours >= 6; @@ -399,10 +451,28 @@ final dashboardMetricsProvider = Provider>((ref) { } } else { // Not checked in yet, no completed logs. - if (isOnCall) { - // ON CALL staff don't need to be on premise or check in at a - // specific time — they only come when needed. - status = isShiftOver ? 'Off duty' : 'ON CALL'; + // + // Check whether ANY of the user's schedules is on_call so we + // can apply on-call logic even when the "most relevant" + // schedule that was selected is a regular shift. + final anyOnCallActive = userSchedules.any( + (s) => + s.shiftType == 'on_call' && + !now.isBefore(s.startTime) && + now.isBefore(s.endTime), + ); + final onlyOnCallSchedules = userSchedules.every( + (s) => s.shiftType == 'on_call', + ); + + if (anyOnCallActive) { + // An on_call shift is currently in its window → ON CALL. + status = 'ON CALL'; + } else if (isOnCall || onlyOnCallSchedules) { + // Selected schedule is on_call, or user has ONLY on_call + // schedules with none currently active → Off duty (between + // on-call shifts). On-call staff can never be Late/Absent. + status = 'Off duty'; } else if (isShiftOver) { // Shift ended with no check-in at all → Absent. status = 'Absent'; @@ -435,6 +505,8 @@ final dashboardMetricsProvider = Provider>((ref) { isrAsync.valueOrNull ?? [], now, ), + avatarUrl: staff.avatarUrl, + teamColor: teamColorByUser[staff.id], ); }).toList(); @@ -865,14 +937,28 @@ class _StaffTableHeader extends StatelessWidget { final style = Theme.of( context, ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700); + final isMobile = AppBreakpoints.isMobile(context); return Row( children: [ - Expanded(flex: 3, child: Text('IT Staff', style: style)), + Expanded( + flex: isMobile ? 2 : 3, + child: Text('IT Staff', style: style), + ), Expanded(flex: 2, child: Text('Status', style: style)), - Expanded(flex: 2, child: Text('Whereabouts', style: style)), - Expanded(flex: 2, child: Text('Tickets', style: style)), - Expanded(flex: 2, child: Text('Tasks', style: style)), - Expanded(flex: 2, child: Text('Events', style: style)), + if (!isMobile) + Expanded(flex: 2, child: Text('Whereabouts', style: style)), + Expanded( + flex: isMobile ? 1 : 2, + child: Text(isMobile ? 'Tix' : 'Tickets', style: style), + ), + Expanded( + flex: isMobile ? 1 : 2, + child: Text(isMobile ? 'Tsk' : 'Tasks', style: style), + ), + Expanded( + flex: isMobile ? 1 : 2, + child: Text(isMobile ? 'Evt' : 'Events', style: style), + ), ], ); } @@ -935,11 +1021,53 @@ class _StaffRow extends StatelessWidget { @override Widget build(BuildContext context) { final valueStyle = Theme.of(context).textTheme.bodySmall; + final isMobile = AppBreakpoints.isMobile(context); + + // Team color dot + Widget? teamDot; + if (row.teamColor != null) { + final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000); + teamDot = Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ); + } + + // IT Staff cell: avatar on mobile, name on desktop, with team color dot + Widget staffCell; + if (isMobile) { + staffCell = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (teamDot != null) ...[teamDot, const SizedBox(width: 4)], + Flexible( + child: Tooltip( + message: row.name, + child: ProfileAvatar( + fullName: row.name, + avatarUrl: row.avatarUrl, + radius: 14, + ), + ), + ), + ], + ); + } else { + staffCell = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (teamDot != null) ...[teamDot, const SizedBox(width: 6)], + Flexible(child: Text(row.name, style: valueStyle)), + ], + ); + } + return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ - Expanded(flex: 3, child: Text(row.name, style: valueStyle)), + Expanded(flex: isMobile ? 2 : 3, child: staffCell), Expanded( flex: 2, child: Align( @@ -947,33 +1075,36 @@ class _StaffRow extends StatelessWidget { child: _PulseStatusPill(label: row.status), ), ), - Expanded( - flex: 2, - child: Text( - row.whereabouts, - style: valueStyle?.copyWith( - color: row.whereabouts == 'In premise' - ? Colors.green - : row.whereabouts == 'Outside premise' - ? Colors.orange - : null, - fontWeight: FontWeight.w600, + if (!isMobile) + Expanded( + flex: 2, + child: Text( + row.whereabouts, + style: valueStyle?.copyWith( + color: row.whereabouts == 'In premise' + ? Colors.green + : row.whereabouts == 'Outside premise' + ? Colors.grey + : row.whereabouts == 'Tracking off' + ? Colors.grey + : null, + fontWeight: FontWeight.w600, + ), ), ), - ), Expanded( - flex: 2, + flex: isMobile ? 1 : 2, child: Text( row.ticketsRespondedToday.toString(), style: valueStyle, ), ), Expanded( - flex: 2, + flex: isMobile ? 1 : 2, child: Text(row.tasksClosedToday.toString(), style: valueStyle), ), Expanded( - flex: 2, + flex: isMobile ? 1 : 2, child: Text(row.eventsHandledToday.toString(), style: valueStyle), ), ], diff --git a/lib/screens/teams/teams_screen.dart b/lib/screens/teams/teams_screen.dart index 8d327153..e44eeb3f 100644 --- a/lib/screens/teams/teams_screen.dart +++ b/lib/screens/teams/teams_screen.dart @@ -277,6 +277,9 @@ class _TeamsScreenState extends ConsumerState { final itStaff = profiles.where((p) => p.role == 'it_staff').toList(); final nameController = TextEditingController(text: team?.name ?? ''); String? leaderId = team?.leaderId; + Color? teamColor = team?.color != null + ? Color(int.parse(team!.color!, radix: 16) | 0xFF000000) + : null; List selectedOffices = List.from(team?.officeIds ?? []); List selectedMembers = []; final isEdit = team != null; @@ -305,6 +308,11 @@ class _TeamsScreenState extends ConsumerState { decoration: const InputDecoration(labelText: 'Team Name'), ), const SizedBox(height: 12), + _TeamColorPicker( + selectedColor: teamColor, + onColorChanged: (c) => setState(() => teamColor = c), + ), + const SizedBox(height: 12), DropdownButtonFormField( initialValue: leaderId, items: [ @@ -408,6 +416,11 @@ class _TeamsScreenState extends ConsumerState { 'name': name, 'leader_id': leaderId, 'office_ids': selectedOffices, + 'color': teamColor != null + ? (teamColor!.toARGB32() & 0xFFFFFF) + .toRadixString(16) + .padLeft(6, '0') + : null, }) .eq('id', team.id); final upErr = extractSupabaseError(upRes); @@ -454,6 +467,11 @@ class _TeamsScreenState extends ConsumerState { 'name': name, 'leader_id': leaderId, 'office_ids': selectedOffices, + 'color': teamColor != null + ? (teamColor!.toARGB32() & 0xFFFFFF) + .toRadixString(16) + .padLeft(6, '0') + : null, }) .select() .single(); @@ -657,3 +675,90 @@ class _TeamsScreenState extends ConsumerState { } } } + +/// Inline color picker for team color selection. +class _TeamColorPicker extends StatelessWidget { + const _TeamColorPicker({ + required this.selectedColor, + required this.onColorChanged, + }); + + final Color? selectedColor; + final ValueChanged onColorChanged; + + static const List _presetColors = [ + Color(0xFFE53935), // Red + Color(0xFFD81B60), // Pink + Color(0xFF8E24AA), // Purple + Color(0xFF5E35B1), // Deep Purple + Color(0xFF3949AB), // Indigo + Color(0xFF1E88E5), // Blue + Color(0xFF039BE5), // Light Blue + Color(0xFF00ACC1), // Cyan + Color(0xFF00897B), // Teal + Color(0xFF43A047), // Green + Color(0xFF7CB342), // Light Green + Color(0xFFFDD835), // Yellow + Color(0xFFFFB300), // Amber + Color(0xFFFB8C00), // Orange + Color(0xFFF4511E), // Deep Orange + Color(0xFF6D4C41), // Brown + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Team Color', style: Theme.of(context).textTheme.bodyMedium), + if (selectedColor != null) ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => onColorChanged(null), + child: Text( + 'Clear', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _presetColors.map((color) { + final isSelected = + selectedColor != null && + (selectedColor!.toARGB32() & 0xFFFFFF) == + (color.toARGB32() & 0xFFFFFF); + return GestureDetector( + onTap: () => onColorChanged(color), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: isSelected + ? Border.all( + color: Theme.of(context).colorScheme.onSurface, + width: 3, + ) + : null, + ), + child: isSelected + ? Icon(Icons.check, color: Colors.white, size: 18) + : null, + ), + ); + }).toList(), + ), + ], + ); + } +} diff --git a/lib/screens/whereabouts/whereabouts_screen.dart b/lib/screens/whereabouts/whereabouts_screen.dart index a42ddfa3..53bb8083 100644 --- a/lib/screens/whereabouts/whereabouts_screen.dart +++ b/lib/screens/whereabouts/whereabouts_screen.dart @@ -4,14 +4,50 @@ 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/live_position.dart'; import '../../models/profile.dart'; +import '../../providers/attendance_provider.dart'; import '../../providers/profile_provider.dart'; import '../../providers/whereabouts_provider.dart'; import '../../providers/workforce_provider.dart'; +import '../../theme/app_surfaces.dart'; import '../../widgets/responsive_body.dart'; import '../../utils/app_time.dart'; +/// Roles shown in the whereabouts tracker. +const _trackedRoles = {'admin', 'dispatcher', 'it_staff'}; + +/// Role color mapping shared between map pins and legend. +Color _roleColor(String? role) { + return switch (role) { + 'admin' => Colors.blue.shade700, + 'it_staff' => Colors.green.shade700, + 'dispatcher' => Colors.orange.shade700, + _ => Colors.grey, + }; +} + +String _roleLabel(String role) { + return switch (role) { + 'admin' => 'Admin', + 'dispatcher' => 'Dispatcher', + 'it_staff' => 'IT Staff', + _ => 'Standard', + }; +} + +String _timeAgo(DateTime dt) { + final diff = AppTime.now().difference(dt); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + return '${diff.inHours}h ago'; +} + +// --------------------------------------------------------------------------- +// Whereabouts Screen +// --------------------------------------------------------------------------- + class WhereaboutsScreen extends ConsumerStatefulWidget { const WhereaboutsScreen({super.key}); @@ -27,10 +63,37 @@ class _WhereaboutsScreenState extends ConsumerState { final positionsAsync = ref.watch(livePositionsProvider); final profilesAsync = ref.watch(profilesProvider); final geofenceAsync = ref.watch(geofenceProvider); + final logsAsync = ref.watch(attendanceLogsProvider); + + final profiles = profilesAsync.valueOrNull ?? const []; + final positions = positionsAsync.valueOrNull ?? const []; + final geofenceConfig = geofenceAsync.valueOrNull; + final allLogs = logsAsync.valueOrNull ?? const []; final Map profileById = { - for (final p in profilesAsync.valueOrNull ?? []) p.id: p, + for (final p in profiles) p.id: p, }; + final Map positionByUser = { + for (final p in positions) p.userId: p, + }; + + // Build active-check-in set for tracking-off users who are still + // inside the geofence (checked in today and not checked out). + final now = AppTime.now(); + final startOfDay = DateTime(now.year, now.month, now.day); + final activeCheckInUsers = {}; + for (final log in allLogs) { + if (!log.checkInAt.isBefore(startOfDay) && !log.isCheckedOut) { + activeCheckInUsers.add(log.userId); + } + } + + // All admin / dispatcher / it_staff profiles, sorted by name. + final trackedProfiles = + profiles.where((p) => _trackedRoles.contains(p.role)).toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + + final isLoading = positionsAsync.isLoading && !positionsAsync.hasValue; return ResponsiveBody( maxWidth: 1200, @@ -46,41 +109,69 @@ class _WhereaboutsScreenState extends ConsumerState { ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), ), + // Map Expanded( - child: positionsAsync.when( - data: (positions) => _buildMap( - context, - positions, - profileById, - geofenceAsync.valueOrNull, - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, _) => - Center(child: Text('Failed to load positions: $e')), + flex: 3, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : ClipRRect( + borderRadius: BorderRadius.circular(16), + child: _WhereaboutsMap( + mapController: _mapController, + positions: positions, + profileById: profileById, + geofenceConfig: geofenceConfig, + ), + ), ), ), - // Staff list below the map - positionsAsync.when( - data: (positions) => - _buildStaffList(context, positions, profileById), - loading: () => const SizedBox.shrink(), - error: (_, _) => const SizedBox.shrink(), + const SizedBox(height: 8), + // Staff Legend Panel (custom widget outside the map) + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _StaffLegendPanel( + profiles: trackedProfiles, + positionByUser: positionByUser, + activeCheckInUsers: activeCheckInUsers, + mapController: _mapController, + ), + ), ), + const SizedBox(height: 8), ], ), ); } +} - Widget _buildMap( - BuildContext context, - List positions, - Map profileById, - GeofenceConfig? geofenceConfig, - ) { - // Only pin users who are in-premise (privacy: don't reveal off-site locations). - final inPremisePositions = positions.where((pos) => pos.inPremise).toList(); +// --------------------------------------------------------------------------- +// Map widget +// --------------------------------------------------------------------------- - final markers = inPremisePositions.map((pos) { +class _WhereaboutsMap extends StatelessWidget { + const _WhereaboutsMap({ + required this.mapController, + required this.positions, + required this.profileById, + required this.geofenceConfig, + }); + + final MapController mapController; + final List positions; + final Map profileById; + final GeofenceConfig? geofenceConfig; + + @override + Widget build(BuildContext context) { + // Only pin in-premise users — outside-geofence users are greyed out + // in the legend and their exact location is not shown on the map. + final inPremise = positions.where((p) => p.inPremise).toList(); + + final markers = inPremise.map((pos) { final profile = profileById[pos.userId]; final name = profile?.fullName ?? 'Unknown'; final pinColor = _roleColor(profile?.role); @@ -117,10 +208,11 @@ class _WhereaboutsScreenState extends ConsumerState { ); }).toList(); - // Build geofence polygon overlay if available + // Geofence polygon overlay final polygonLayers = []; - if (geofenceConfig != null && geofenceConfig.hasPolygon) { - final List points = geofenceConfig.polygon! + final geoConfig = geofenceConfig; + if (geoConfig != null && geoConfig.hasPolygon) { + final points = geoConfig.polygon! .map((p) => LatLng(p.lat, p.lng)) .toList(); if (points.isNotEmpty) { @@ -143,10 +235,10 @@ class _WhereaboutsScreenState extends ConsumerState { const defaultCenter = LatLng(7.2046, 124.2460); return FlutterMap( - mapController: _mapController, + mapController: mapController, options: MapOptions( - initialCenter: positions.isNotEmpty - ? LatLng(positions.first.lat, positions.first.lng) + initialCenter: inPremise.isNotEmpty + ? LatLng(inPremise.first.lat, inPremise.first.lng) : defaultCenter, initialZoom: 16.0, ), @@ -157,154 +249,253 @@ class _WhereaboutsScreenState extends ConsumerState { ), ...polygonLayers, MarkerLayer(markers: markers), + // OSM attribution (required by OpenStreetMap tile usage policy). + const RichAttributionWidget( + alignment: AttributionAlignment.bottomLeft, + attributions: [TextSourceAttribution('OpenStreetMap contributors')], + ), ], ); } +} - Widget _buildStaffList( - BuildContext context, - List positions, - Map profileById, - ) { - if (positions.isEmpty) return const SizedBox.shrink(); +// --------------------------------------------------------------------------- +// Staff Legend Panel — sits outside the map +// --------------------------------------------------------------------------- - // Only include Admin, IT Staff, and Dispatcher in the legend. - final relevantRoles = {'admin', 'dispatcher', 'it_staff'}; - final legendEntries = positions.where((pos) { - final role = profileById[pos.userId]?.role; - return role != null && relevantRoles.contains(role); - }).toList(); +class _StaffLegendPanel extends StatelessWidget { + const _StaffLegendPanel({ + required this.profiles, + required this.positionByUser, + required this.activeCheckInUsers, + required this.mapController, + }); - if (legendEntries.isEmpty) return const SizedBox.shrink(); + final List profiles; + final Map positionByUser; + final Set activeCheckInUsers; + final MapController mapController; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final surfaces = AppSurfaces.of(context); return Container( - constraints: const BoxConstraints(maxHeight: 220), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(surfaces.cardRadius), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Role color legend header + // Header with role badges Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ - _legendDot(Colors.blue.shade700), - const SizedBox(width: 4), - Text('Admin', style: Theme.of(context).textTheme.labelSmall), - const SizedBox(width: 12), - _legendDot(Colors.green.shade700), - const SizedBox(width: 4), - Text('IT Staff', style: Theme.of(context).textTheme.labelSmall), - const SizedBox(width: 12), - _legendDot(Colors.orange.shade700), - const SizedBox(width: 4), Text( - 'Dispatcher', - style: Theme.of(context).textTheme.labelSmall, + 'Staff', + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ), + const Spacer(), + _RoleBadge(color: Colors.blue.shade700, label: 'Admin'), + const SizedBox(width: 12), + _RoleBadge(color: Colors.green.shade700, label: 'IT Staff'), + const SizedBox(width: 12), + _RoleBadge(color: Colors.orange.shade700, label: 'Dispatcher'), ], ), ), + const Divider(height: 1), // Staff entries Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - itemCount: legendEntries.length, - itemBuilder: (context, index) { - final pos = legendEntries[index]; - final p = profileById[pos.userId]; - final name = p?.fullName ?? 'Unknown'; - final role = p?.role ?? '-'; - final isInPremise = pos.inPremise; - final pinColor = _roleColor(role); - final timeAgo = _timeAgo(pos.updatedAt); - // Grey out outside-premise users for privacy. - final effectiveColor = isInPremise - ? pinColor - : Colors.grey.shade400; - - return ListTile( - dense: true, - leading: CircleAvatar( - radius: 16, - backgroundColor: effectiveColor.withValues(alpha: 0.2), - child: Icon( - isInPremise ? Icons.location_pin : Icons.location_off, - size: 16, - color: effectiveColor, + child: profiles.isEmpty + ? Center( + child: Text( + 'No tracked staff', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ), ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: profiles.length, + separatorBuilder: (_, _) => + const Divider(height: 1, indent: 56), + itemBuilder: (context, index) { + final profile = profiles[index]; + final position = positionByUser[profile.id]; + final hasActiveCheckIn = activeCheckInUsers.contains( + profile.id, + ); + return _StaffLegendTile( + profile: profile, + position: position, + hasActiveCheckIn: hasActiveCheckIn, + onTap: position != null && position.inPremise + ? () => mapController.move( + LatLng(position.lat, position.lng), + 17.0, + ) + : null, + ); + }, ), - title: Text( - name, - style: TextStyle( - color: isInPremise ? null : Colors.grey, - fontWeight: isInPremise - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - subtitle: Text( - '${_roleLabel(role)} · ${isInPremise ? timeAgo : 'Outside premise'}', - style: TextStyle(color: isInPremise ? null : Colors.grey), - ), - trailing: isInPremise - ? Icon(Icons.circle, size: 10, color: pinColor) - : Icon( - Icons.circle, - size: 10, - color: Colors.grey.shade300, - ), - onTap: isInPremise - ? () => - _mapController.move(LatLng(pos.lat, pos.lng), 17.0) - : null, - ); - }, - ), ), ], ), ); } +} - Widget _legendDot(Color color) { - return Container( - width: 10, - height: 10, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), +// --------------------------------------------------------------------------- +// Role badge (dot + label) +// --------------------------------------------------------------------------- + +class _RoleBadge extends StatelessWidget { + const _RoleBadge({required this.color, required this.label}); + + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ); } +} - /// Returns the pin color for a given role. - static Color _roleColor(String? role) { - switch (role) { - case 'admin': - return Colors.blue.shade700; - case 'it_staff': - return Colors.green.shade700; - case 'dispatcher': - return Colors.orange.shade700; - default: - return Colors.grey; +// --------------------------------------------------------------------------- +// Individual staff legend tile +// --------------------------------------------------------------------------- + +class _StaffLegendTile extends StatelessWidget { + const _StaffLegendTile({ + required this.profile, + required this.position, + required this.hasActiveCheckIn, + this.onTap, + }); + + final Profile profile; + final LivePosition? position; + final bool hasActiveCheckIn; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final roleColor = _roleColor(profile.role); + + final hasPosition = position != null; + final isInPremise = position?.inPremise ?? false; + final isTrackingOff = !profile.allowTracking; + + // Determine display state + final bool isActive = hasPosition && isInPremise; + final bool isGreyedOut = !isActive; + + // For tracking-off users without a live position, infer from check-in. + final bool inferredInPremise = + isTrackingOff && !hasPosition && hasActiveCheckIn; + + final effectiveColor = (isActive || inferredInPremise) + ? roleColor + : Colors.grey.shade400; + + // Build status label + final String statusText; + final Color statusColor; + if (isTrackingOff) { + if (hasPosition) { + statusText = isInPremise + ? 'In premise (Tracking off)' + : 'Outside premise (Tracking off)'; + statusColor = isInPremise ? Colors.green : Colors.grey; + } else if (inferredInPremise) { + statusText = 'In premise (Checked in)'; + statusColor = Colors.green; + } else { + statusText = 'Tracking off'; + statusColor = Colors.grey; + } + } else if (isActive) { + statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}'; + statusColor = Colors.green; + } else if (hasPosition) { + statusText = 'Outside premise'; + statusColor = Colors.grey; + } else { + statusText = 'No location data'; + statusColor = Colors.grey; } - } - String _timeAgo(DateTime dt) { - final diff = AppTime.now().difference(dt); - if (diff.inMinutes < 1) return 'Just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - return '${diff.inHours}h ago'; - } - - String _roleLabel(String role) { - switch (role) { - case 'admin': - return 'Admin'; - case 'dispatcher': - return 'Dispatcher'; - case 'it_staff': - return 'IT Staff'; - default: - return 'Standard'; + final IconData statusIcon; + if (isTrackingOff) { + statusIcon = Icons.location_disabled; + } else if (isActive) { + statusIcon = Icons.location_on; + } else if (hasPosition) { + statusIcon = Icons.location_off; + } else { + statusIcon = Icons.location_searching; } + + return ListTile( + dense: true, + onTap: onTap, + leading: CircleAvatar( + radius: 18, + backgroundColor: effectiveColor.withValues(alpha: 0.15), + child: Icon(statusIcon, size: 18, color: effectiveColor), + ), + title: Text( + profile.fullName.isNotEmpty ? profile.fullName : 'Unknown', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: (isActive || inferredInPremise) ? FontWeight.w600 : null, + color: (isGreyedOut && !inferredInPremise) + ? cs.onSurfaceVariant.withValues(alpha: 0.6) + : null, + ), + ), + subtitle: Text( + '${_roleLabel(profile.role)} \u00b7 $statusText', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: (isGreyedOut && !inferredInPremise) + ? cs.onSurfaceVariant.withValues(alpha: 0.5) + : statusColor, + ), + ), + trailing: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: (isActive || inferredInPremise) + ? roleColor + : Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + ); } } diff --git a/lib/services/background_location_service.dart b/lib/services/background_location_service.dart index d12b4afd..57cb8ccc 100644 --- a/lib/services/background_location_service.dart +++ b/lib/services/background_location_service.dart @@ -54,12 +54,14 @@ void callbackDispatcher() { /// Initialize Workmanager and register periodic background location task. Future initBackgroundLocationService() async { + if (kIsWeb) return; // Workmanager is not supported on web. await Workmanager().initialize(callbackDispatcher); } /// Register a periodic task to report location every ~15 minutes /// (Android minimum for periodic Workmanager tasks). Future startBackgroundLocationUpdates() async { + if (kIsWeb) return; // Workmanager is not supported on web. await Workmanager().registerPeriodicTask( _taskName, _taskName, @@ -71,5 +73,6 @@ Future startBackgroundLocationUpdates() async { /// Cancel the periodic background location task. Future stopBackgroundLocationUpdates() async { + if (kIsWeb) return; // Workmanager is not supported on web. await Workmanager().cancelByUniqueName(_taskName); } diff --git a/lib/services/image_compress_service.dart b/lib/services/image_compress_service.dart new file mode 100644 index 00000000..e43eb2be --- /dev/null +++ b/lib/services/image_compress_service.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import 'package:flutter_image_compress/flutter_image_compress.dart'; + +/// Compresses a JPEG image to reduce upload size. +/// +/// Target: ≤ 200 KB for verification selfies. Falls back to the original +/// bytes if compression fails so the upload is never blocked. +class ImageCompressService { + ImageCompressService._(); + + /// Compress [bytes] (JPEG/PNG) down to [quality] (1–100). + /// Resizes the longest side to at most [maxDimension] pixels. + static Future compress( + Uint8List bytes, { + int quality = 70, + int maxDimension = 800, + }) async { + try { + final result = await FlutterImageCompress.compressWithList( + bytes, + minHeight: maxDimension, + minWidth: maxDimension, + quality: quality, + format: CompressFormat.jpeg, + ); + return result; + } catch (_) { + // If compression fails on any platform, return original bytes. + return bytes; + } + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 1af5e2c8..2e485fbe 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -31,6 +31,10 @@ class AppScaffold extends ConsumerWidget { }, orElse: () => 'User', ); + final avatarUrl = profileAsync.maybeWhen( + data: (profile) => profile?.avatarUrl, + orElse: () => null, + ); final isStandard = role == 'standard'; final location = GoRouterState.of(context).uri.toString(); @@ -75,7 +79,11 @@ class AppScaffold extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - ProfileAvatar(fullName: displayName, radius: 16), + ProfileAvatar( + fullName: displayName, + avatarUrl: avatarUrl, + radius: 16, + ), const SizedBox(width: 8), Text(displayName), const SizedBox(width: 4), @@ -91,7 +99,11 @@ class AppScaffold extends ConsumerWidget { IconButton( tooltip: 'Profile', onPressed: () => context.go('/profile'), - icon: ProfileAvatar(fullName: displayName, radius: 16), + icon: ProfileAvatar( + fullName: displayName, + avatarUrl: avatarUrl, + radius: 16, + ), ), IconButton( tooltip: 'Sign out', diff --git a/lib/widgets/face_verification_overlay.dart b/lib/widgets/face_verification_overlay.dart index de25f13e..793a56c1 100644 --- a/lib/widgets/face_verification_overlay.dart +++ b/lib/widgets/face_verification_overlay.dart @@ -8,11 +8,20 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/attendance_provider.dart'; import '../providers/profile_provider.dart'; import '../services/face_verification.dart' as face; +import '../services/image_compress_service.dart'; import '../theme/m3_motion.dart'; import '../widgets/qr_verification_dialog.dart'; /// Phases of the full-screen face verification overlay. -enum _Phase { liveness, downloading, matching, success, failed, cancelled } +enum _Phase { + liveness, + downloading, + matching, + saving, + success, + failed, + cancelled, +} /// Result returned from the overlay. class FaceVerificationResult { @@ -170,14 +179,19 @@ class _FaceVerificationOverlayState final score = await face.compareFaces(result.imageBytes, enrolledBytes); if (score >= 0.60) { - // Success! + // Success! Transition to saving phase for compress + upload. + if (!mounted) return; + setState(() { + _phase = _Phase.saving; + _statusText = 'Compressing & saving photo...'; + }); + await _compressAndUpload(result.imageBytes, 'verified'); if (!mounted) return; setState(() { _phase = _Phase.success; _statusText = 'Face verified!\n${(score * 100).toStringAsFixed(0)}% match'; }); - await _uploadResult(result.imageBytes, 'verified'); await Future.delayed(const Duration(milliseconds: 1200)); if (mounted) { Navigator.of( @@ -201,13 +215,18 @@ class _FaceVerificationOverlayState } else { // All attempts exhausted if (!mounted) return; + setState(() { + _phase = _Phase.saving; + _statusText = 'Compressing & saving photo...'; + }); + await _compressAndUpload(result.imageBytes, 'unverified'); + if (!mounted) return; setState(() { _phase = _Phase.failed; _statusText = 'Face did not match after ${widget.maxAttempts} attempts\n' '${(score * 100).toStringAsFixed(0)}% similarity'; }); - await _uploadResult(result.imageBytes, 'unverified'); await Future.delayed(const Duration(milliseconds: 1500)); if (mounted) { Navigator.of( @@ -218,16 +237,17 @@ class _FaceVerificationOverlayState } } - Future _uploadResult(Uint8List bytes, String status) async { + Future _compressAndUpload(Uint8List bytes, String status) async { if (!widget.uploadAttendanceResult || widget.attendanceLogId == null) { return; } try { + final compressed = await ImageCompressService.compress(bytes); await ref .read(attendanceControllerProvider) .uploadVerification( attendanceId: widget.attendanceLogId!, - bytes: bytes, + bytes: compressed, fileName: 'verification.jpg', status: status, ); @@ -280,6 +300,8 @@ class _FaceVerificationOverlayState ? Colors.green : _phase == _Phase.failed ? colors.error + : _phase == _Phase.saving + ? colors.tertiary : colors.onSurface, fontWeight: FontWeight.w600, ), @@ -304,6 +326,7 @@ class _FaceVerificationOverlayState _phase == _Phase.liveness || _phase == _Phase.downloading || _phase == _Phase.matching; + final isSaving = _phase == _Phase.saving; final isSuccess = _phase == _Phase.success; final isFailed = _phase == _Phase.failed; @@ -312,6 +335,8 @@ class _FaceVerificationOverlayState ringColor = Colors.green; } else if (isFailed) { ringColor = colors.error; + } else if (isSaving) { + ringColor = colors.tertiary; } else { ringColor = colors.primary; } @@ -324,7 +349,9 @@ class _FaceVerificationOverlayState children: [ // Pulsing ring ScaleTransition( - scale: isActive ? _pulseAnim : const AlwaysStoppedAnimation(1.0), + scale: isActive || isSaving + ? _pulseAnim + : const AlwaysStoppedAnimation(1.0), child: Container( width: 180, height: 180, @@ -355,6 +382,8 @@ class _FaceVerificationOverlayState ? Icons.check_circle_rounded : isFailed ? Icons.error_rounded + : isSaving + ? Icons.cloud_upload_rounded : Icons.face_rounded, key: ValueKey(_phase), size: 64, @@ -400,6 +429,14 @@ class _FaceVerificationOverlayState borderRadius: BorderRadius.circular(4), ); } + if (_phase == _Phase.saving) { + return LinearProgressIndicator( + value: null, + backgroundColor: colors.tertiary.withValues(alpha: 0.12), + color: colors.tertiary, + borderRadius: BorderRadius.circular(4), + ); + } return LinearProgressIndicator( value: null, backgroundColor: colors.primary.withValues(alpha: 0.12), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 16cc7d88..23acd1c1 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import file_picker import file_selector_macos import firebase_core import firebase_messaging +import flutter_image_compress_macos import flutter_local_notifications import geolocator_apple import printing @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 137e5e43..84ce68e3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -518,6 +518,54 @@ packages: url: "https://pub.dev" source: hosted version: "5.2.1" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.dev" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.dev" + source: hosted + version: "0.1.5" flutter_keyboard_visibility: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index eebb585d..4a78955b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: flutter_liveness_check: ^1.0.3 google_mlkit_face_detection: ^0.13.2 qr_flutter: ^4.1.0 + flutter_image_compress: ^2.4.0 dev_dependencies: flutter_test: