diff --git a/assets/no_team.jpg b/assets/no_team.jpg new file mode 100644 index 00000000..39b8e2b3 Binary files /dev/null and b/assets/no_team.jpg differ diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index bda6a8fe..b005b2f8 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -379,7 +379,20 @@ final dashboardMetricsProvider = Provider>((ref) { final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut); final String whereabouts; if (livePos != null) { - whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise'; + final stale = + 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'; + whereabouts = livePos.inPremise + ? 'Last seen in premise \u00b7 $ago' + : 'Last seen outside \u00b7 $ago'; + } else { + 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'; @@ -944,20 +957,28 @@ class _StaffTableHeader extends StatelessWidget { flex: isMobile ? 2 : 3, child: Text('IT Staff', style: style), ), - Expanded(flex: 2, child: Text('Status', style: style)), + Expanded( + flex: 2, + child: Center(child: Text('Status', style: style)), + ), if (!isMobile) - Expanded(flex: 2, child: Text('Whereabouts', style: style)), + Expanded( + flex: 4, + child: Center(child: Text('Whereabouts', style: style)), + ), Expanded( - flex: isMobile ? 1 : 2, - child: Text(isMobile ? 'Tix' : 'Tickets', style: style), + flex: isMobile ? 1 : 1, + child: Center( + child: Text(isMobile ? 'Tix' : 'Tickets', style: style), + ), ), Expanded( - flex: isMobile ? 1 : 2, - child: Text(isMobile ? 'Tsk' : 'Tasks', style: style), + flex: isMobile ? 1 : 1, + child: Center(child: Text(isMobile ? 'Tsk' : 'Tasks', style: style)), ), Expanded( - flex: isMobile ? 1 : 2, - child: Text(isMobile ? 'Evt' : 'Events', style: style), + flex: isMobile ? 1 : 1, + child: Center(child: Text(isMobile ? 'Evt' : 'Events', style: style)), ), ], ); @@ -1023,15 +1044,24 @@ class _StaffRow extends StatelessWidget { final valueStyle = Theme.of(context).textTheme.bodySmall; final isMobile = AppBreakpoints.isMobile(context); - // Team color dot - Widget? teamDot; - if (row.teamColor != null) { + // Team marker: use team color dot when assigned, otherwise a circular no-team image. + final Widget teamMarker; + if (row.teamColor != null && row.teamColor!.isNotEmpty) { final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000); - teamDot = Container( + teamMarker = Container( width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ); + } else { + teamMarker = ClipOval( + child: Image.asset( + 'assets/no_team.jpg', + width: 14, + height: 14, + fit: BoxFit.cover, + ), + ); } // IT Staff cell: avatar on mobile, name on desktop, with team color dot @@ -1040,7 +1070,8 @@ class _StaffRow extends StatelessWidget { staffCell = Row( mainAxisSize: MainAxisSize.min, children: [ - if (teamDot != null) ...[teamDot, const SizedBox(width: 4)], + teamMarker, + const SizedBox(width: 4), Flexible( child: Tooltip( message: row.name, @@ -1057,7 +1088,8 @@ class _StaffRow extends StatelessWidget { staffCell = Row( mainAxisSize: MainAxisSize.min, children: [ - if (teamDot != null) ...[teamDot, const SizedBox(width: 6)], + teamMarker, + const SizedBox(width: 6), Flexible(child: Text(row.name, style: valueStyle)), ], ); @@ -1070,42 +1102,49 @@ class _StaffRow extends StatelessWidget { Expanded(flex: isMobile ? 2 : 3, child: staffCell), Expanded( flex: 2, - child: Align( - alignment: Alignment.centerLeft, - child: _PulseStatusPill(label: row.status), - ), + child: Center(child: _PulseStatusPill(label: row.status)), ), 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, + flex: 4, + child: Center( + 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 + : row.whereabouts.startsWith('Last seen') + ? Colors.grey + : null, + fontWeight: FontWeight.w600, + ), ), ), ), Expanded( - flex: isMobile ? 1 : 2, - child: Text( - row.ticketsRespondedToday.toString(), - style: valueStyle, + flex: isMobile ? 1 : 1, + child: Center( + child: Text( + row.ticketsRespondedToday.toString(), + style: valueStyle, + ), ), ), Expanded( - flex: isMobile ? 1 : 2, - child: Text(row.tasksClosedToday.toString(), style: valueStyle), + flex: isMobile ? 1 : 1, + child: Center( + child: Text(row.tasksClosedToday.toString(), style: valueStyle), + ), ), Expanded( - flex: isMobile ? 1 : 2, - child: Text(row.eventsHandledToday.toString(), style: valueStyle), + flex: isMobile ? 1 : 1, + child: Center( + child: Text(row.eventsHandledToday.toString(), style: valueStyle), + ), ), ], ), diff --git a/lib/screens/whereabouts/whereabouts_screen.dart b/lib/screens/whereabouts/whereabouts_screen.dart index 53bb8083..82056956 100644 --- a/lib/screens/whereabouts/whereabouts_screen.dart +++ b/lib/screens/whereabouts/whereabouts_screen.dart @@ -37,11 +37,19 @@ String _roleLabel(String role) { }; } +/// Threshold after which a live position is considered stale. +const _staleDuration = Duration(minutes: 15); + 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'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; +} + +bool _isStale(DateTime dt) { + return AppTime.now().difference(dt) > _staleDuration; } // --------------------------------------------------------------------------- @@ -167,43 +175,49 @@ class _WhereaboutsMap extends StatelessWidget { @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. + // Show in-premise users on the map; grey out stale positions. 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); + final stale = _isStale(pos.updatedAt); + final pinColor = stale ? Colors.grey : _roleColor(profile?.role); return Marker( point: LatLng(pos.lat, pos.lng), width: 80, height: 60, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 4, + child: Opacity( + opacity: stale ? 0.5 : 1.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 4, + ), + ], + ), + child: Text( + name.split(' ').first, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + color: stale + ? Theme.of(context).colorScheme.onSurfaceVariant + : null, ), - ], + overflow: TextOverflow.ellipsis, + ), ), - child: Text( - name.split(' ').first, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - ), - Icon(Icons.location_pin, size: 28, color: pinColor), - ], + Icon(Icons.location_pin, size: 28, color: pinColor), + ], + ), ), ); }).toList(); @@ -410,9 +424,10 @@ class _StaffLegendTile extends StatelessWidget { final hasPosition = position != null; final isInPremise = position?.inPremise ?? false; final isTrackingOff = !profile.allowTracking; + final isStale = hasPosition && _isStale(position!.updatedAt); // Determine display state - final bool isActive = hasPosition && isInPremise; + final bool isActive = hasPosition && isInPremise && !isStale; final bool isGreyedOut = !isActive; // For tracking-off users without a live position, infer from check-in. @@ -428,10 +443,13 @@ class _StaffLegendTile extends StatelessWidget { final Color statusColor; if (isTrackingOff) { if (hasPosition) { + final timeLabel = _timeAgo(position!.updatedAt); statusText = isInPremise - ? 'In premise (Tracking off)' - : 'Outside premise (Tracking off)'; - statusColor = isInPremise ? Colors.green : Colors.grey; + ? 'In premise (Tracking off) \u00b7 $timeLabel' + : 'Outside premise (Tracking off) \u00b7 $timeLabel'; + statusColor = isStale + ? Colors.grey + : (isInPremise ? Colors.green : Colors.grey); } else if (inferredInPremise) { statusText = 'In premise (Checked in)'; statusColor = Colors.green; @@ -439,11 +457,17 @@ class _StaffLegendTile extends StatelessWidget { statusText = 'Tracking off'; statusColor = Colors.grey; } + } else if (isStale && hasPosition) { + final timeLabel = _timeAgo(position!.updatedAt); + statusText = isInPremise + ? 'Last seen in premise \u00b7 $timeLabel' + : 'Last seen outside premise \u00b7 $timeLabel'; + statusColor = Colors.grey; } else if (isActive) { statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}'; statusColor = Colors.green; } else if (hasPosition) { - statusText = 'Outside premise'; + statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}'; statusColor = Colors.grey; } else { statusText = 'No location data'; @@ -453,6 +477,8 @@ class _StaffLegendTile extends StatelessWidget { final IconData statusIcon; if (isTrackingOff) { statusIcon = Icons.location_disabled; + } else if (isStale) { + statusIcon = Icons.location_off; } else if (isActive) { statusIcon = Icons.location_on; } else if (hasPosition) {