Whereaboutes and dashboard IT Staff Pulse enhancements
This commit is contained in:
parent
9178b438a2
commit
f8502f01b6
BIN
assets/no_team.jpg
Normal file
BIN
assets/no_team.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -379,7 +379,20 @@ final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
|
||||||
final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut);
|
final hasActiveCheckIn = userLogs.any((l) => !l.isCheckedOut);
|
||||||
final String whereabouts;
|
final String whereabouts;
|
||||||
if (livePos != null) {
|
if (livePos != null) {
|
||||||
|
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';
|
whereabouts = livePos.inPremise ? 'In premise' : 'Outside premise';
|
||||||
|
}
|
||||||
} else if (!staff.allowTracking) {
|
} else if (!staff.allowTracking) {
|
||||||
// Tracking off — infer from active check-in (geofence validated).
|
// Tracking off — infer from active check-in (geofence validated).
|
||||||
whereabouts = hasActiveCheckIn ? 'In premise' : 'Tracking off';
|
whereabouts = hasActiveCheckIn ? 'In premise' : 'Tracking off';
|
||||||
|
|
@ -944,20 +957,28 @@ class _StaffTableHeader extends StatelessWidget {
|
||||||
flex: isMobile ? 2 : 3,
|
flex: isMobile ? 2 : 3,
|
||||||
child: Text('IT Staff', style: style),
|
child: Text('IT Staff', style: style),
|
||||||
),
|
),
|
||||||
Expanded(flex: 2, child: Text('Status', style: style)),
|
|
||||||
if (!isMobile)
|
|
||||||
Expanded(flex: 2, child: Text('Whereabouts', style: style)),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isMobile ? 1 : 2,
|
flex: 2,
|
||||||
|
child: Center(child: Text('Status', style: style)),
|
||||||
|
),
|
||||||
|
if (!isMobile)
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: Center(child: Text('Whereabouts', style: style)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: isMobile ? 1 : 1,
|
||||||
|
child: Center(
|
||||||
child: Text(isMobile ? 'Tix' : 'Tickets', style: style),
|
child: Text(isMobile ? 'Tix' : 'Tickets', style: style),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
flex: isMobile ? 1 : 2,
|
|
||||||
child: Text(isMobile ? 'Tsk' : 'Tasks', style: style),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isMobile ? 1 : 2,
|
flex: isMobile ? 1 : 1,
|
||||||
child: Text(isMobile ? 'Evt' : 'Events', style: style),
|
child: Center(child: Text(isMobile ? 'Tsk' : 'Tasks', style: style)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
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 valueStyle = Theme.of(context).textTheme.bodySmall;
|
||||||
final isMobile = AppBreakpoints.isMobile(context);
|
final isMobile = AppBreakpoints.isMobile(context);
|
||||||
|
|
||||||
// Team color dot
|
// Team marker: use team color dot when assigned, otherwise a circular no-team image.
|
||||||
Widget? teamDot;
|
final Widget teamMarker;
|
||||||
if (row.teamColor != null) {
|
if (row.teamColor != null && row.teamColor!.isNotEmpty) {
|
||||||
final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000);
|
final color = Color(int.parse(row.teamColor!, radix: 16) | 0xFF000000);
|
||||||
teamDot = Container(
|
teamMarker = Container(
|
||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
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
|
// IT Staff cell: avatar on mobile, name on desktop, with team color dot
|
||||||
|
|
@ -1040,7 +1070,8 @@ class _StaffRow extends StatelessWidget {
|
||||||
staffCell = Row(
|
staffCell = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (teamDot != null) ...[teamDot, const SizedBox(width: 4)],
|
teamMarker,
|
||||||
|
const SizedBox(width: 4),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Tooltip(
|
child: Tooltip(
|
||||||
message: row.name,
|
message: row.name,
|
||||||
|
|
@ -1057,7 +1088,8 @@ class _StaffRow extends StatelessWidget {
|
||||||
staffCell = Row(
|
staffCell = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (teamDot != null) ...[teamDot, const SizedBox(width: 6)],
|
teamMarker,
|
||||||
|
const SizedBox(width: 6),
|
||||||
Flexible(child: Text(row.name, style: valueStyle)),
|
Flexible(child: Text(row.name, style: valueStyle)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -1070,14 +1102,12 @@ class _StaffRow extends StatelessWidget {
|
||||||
Expanded(flex: isMobile ? 2 : 3, child: staffCell),
|
Expanded(flex: isMobile ? 2 : 3, child: staffCell),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Align(
|
child: Center(child: _PulseStatusPill(label: row.status)),
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: _PulseStatusPill(label: row.status),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (!isMobile)
|
if (!isMobile)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 4,
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
row.whereabouts,
|
row.whereabouts,
|
||||||
style: valueStyle?.copyWith(
|
style: valueStyle?.copyWith(
|
||||||
|
|
@ -1087,26 +1117,35 @@ class _StaffRow extends StatelessWidget {
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
: row.whereabouts == 'Tracking off'
|
: row.whereabouts == 'Tracking off'
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
|
: row.whereabouts.startsWith('Last seen')
|
||||||
|
? Colors.grey
|
||||||
: null,
|
: null,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isMobile ? 1 : 2,
|
flex: isMobile ? 1 : 1,
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
row.ticketsRespondedToday.toString(),
|
row.ticketsRespondedToday.toString(),
|
||||||
style: valueStyle,
|
style: valueStyle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
flex: isMobile ? 1 : 2,
|
|
||||||
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: isMobile ? 1 : 2,
|
flex: isMobile ? 1 : 1,
|
||||||
|
child: Center(
|
||||||
|
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: isMobile ? 1 : 1,
|
||||||
|
child: Center(
|
||||||
child: Text(row.eventsHandledToday.toString(), style: valueStyle),
|
child: Text(row.eventsHandledToday.toString(), style: valueStyle),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
String _timeAgo(DateTime dt) {
|
||||||
final diff = AppTime.now().difference(dt);
|
final diff = AppTime.now().difference(dt);
|
||||||
if (diff.inMinutes < 1) return 'Just now';
|
if (diff.inMinutes < 1) return 'Just now';
|
||||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
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,18 +175,20 @@ class _WhereaboutsMap extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Only pin in-premise users — outside-geofence users are greyed out
|
// Show in-premise users on the map; grey out stale positions.
|
||||||
// in the legend and their exact location is not shown on the map.
|
|
||||||
final inPremise = positions.where((p) => p.inPremise).toList();
|
final inPremise = positions.where((p) => p.inPremise).toList();
|
||||||
|
|
||||||
final markers = inPremise.map((pos) {
|
final markers = inPremise.map((pos) {
|
||||||
final profile = profileById[pos.userId];
|
final profile = profileById[pos.userId];
|
||||||
final name = profile?.fullName ?? 'Unknown';
|
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(
|
return Marker(
|
||||||
point: LatLng(pos.lat, pos.lng),
|
point: LatLng(pos.lat, pos.lng),
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 60,
|
height: 60,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: stale ? 0.5 : 1.0,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -196,15 +206,19 @@ class _WhereaboutsMap extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
name.split(' ').first,
|
name.split(' ').first,
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
context,
|
fontWeight: FontWeight.w600,
|
||||||
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
|
color: stale
|
||||||
|
? Theme.of(context).colorScheme.onSurfaceVariant
|
||||||
|
: null,
|
||||||
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(Icons.location_pin, size: 28, color: pinColor),
|
Icon(Icons.location_pin, size: 28, color: pinColor),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
|
@ -410,9 +424,10 @@ class _StaffLegendTile extends StatelessWidget {
|
||||||
final hasPosition = position != null;
|
final hasPosition = position != null;
|
||||||
final isInPremise = position?.inPremise ?? false;
|
final isInPremise = position?.inPremise ?? false;
|
||||||
final isTrackingOff = !profile.allowTracking;
|
final isTrackingOff = !profile.allowTracking;
|
||||||
|
final isStale = hasPosition && _isStale(position!.updatedAt);
|
||||||
|
|
||||||
// Determine display state
|
// Determine display state
|
||||||
final bool isActive = hasPosition && isInPremise;
|
final bool isActive = hasPosition && isInPremise && !isStale;
|
||||||
final bool isGreyedOut = !isActive;
|
final bool isGreyedOut = !isActive;
|
||||||
|
|
||||||
// For tracking-off users without a live position, infer from check-in.
|
// For tracking-off users without a live position, infer from check-in.
|
||||||
|
|
@ -428,10 +443,13 @@ class _StaffLegendTile extends StatelessWidget {
|
||||||
final Color statusColor;
|
final Color statusColor;
|
||||||
if (isTrackingOff) {
|
if (isTrackingOff) {
|
||||||
if (hasPosition) {
|
if (hasPosition) {
|
||||||
|
final timeLabel = _timeAgo(position!.updatedAt);
|
||||||
statusText = isInPremise
|
statusText = isInPremise
|
||||||
? 'In premise (Tracking off)'
|
? 'In premise (Tracking off) \u00b7 $timeLabel'
|
||||||
: 'Outside premise (Tracking off)';
|
: 'Outside premise (Tracking off) \u00b7 $timeLabel';
|
||||||
statusColor = isInPremise ? Colors.green : Colors.grey;
|
statusColor = isStale
|
||||||
|
? Colors.grey
|
||||||
|
: (isInPremise ? Colors.green : Colors.grey);
|
||||||
} else if (inferredInPremise) {
|
} else if (inferredInPremise) {
|
||||||
statusText = 'In premise (Checked in)';
|
statusText = 'In premise (Checked in)';
|
||||||
statusColor = Colors.green;
|
statusColor = Colors.green;
|
||||||
|
|
@ -439,11 +457,17 @@ class _StaffLegendTile extends StatelessWidget {
|
||||||
statusText = 'Tracking off';
|
statusText = 'Tracking off';
|
||||||
statusColor = Colors.grey;
|
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) {
|
} else if (isActive) {
|
||||||
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
|
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
|
||||||
statusColor = Colors.green;
|
statusColor = Colors.green;
|
||||||
} else if (hasPosition) {
|
} else if (hasPosition) {
|
||||||
statusText = 'Outside premise';
|
statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}';
|
||||||
statusColor = Colors.grey;
|
statusColor = Colors.grey;
|
||||||
} else {
|
} else {
|
||||||
statusText = 'No location data';
|
statusText = 'No location data';
|
||||||
|
|
@ -453,6 +477,8 @@ class _StaffLegendTile extends StatelessWidget {
|
||||||
final IconData statusIcon;
|
final IconData statusIcon;
|
||||||
if (isTrackingOff) {
|
if (isTrackingOff) {
|
||||||
statusIcon = Icons.location_disabled;
|
statusIcon = Icons.location_disabled;
|
||||||
|
} else if (isStale) {
|
||||||
|
statusIcon = Icons.location_off;
|
||||||
} else if (isActive) {
|
} else if (isActive) {
|
||||||
statusIcon = Icons.location_on;
|
statusIcon = Icons.location_on;
|
||||||
} else if (hasPosition) {
|
} else if (hasPosition) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user