Team Color, image compression for attendance verification, improved wherebouts
This commit is contained in:
@@ -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<WhereaboutsScreen> {
|
||||
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 <Profile>[];
|
||||
final positions = positionsAsync.valueOrNull ?? const <LivePosition>[];
|
||||
final geofenceConfig = geofenceAsync.valueOrNull;
|
||||
final allLogs = logsAsync.valueOrNull ?? const <AttendanceLog>[];
|
||||
|
||||
final Map<String, Profile> profileById = {
|
||||
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
|
||||
for (final p in profiles) p.id: p,
|
||||
};
|
||||
final Map<String, LivePosition> 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 = <String>{};
|
||||
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<WhereaboutsScreen> {
|
||||
).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<LivePosition> positions,
|
||||
Map<String, Profile> 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<LivePosition> positions;
|
||||
final Map<String, Profile> 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<WhereaboutsScreen> {
|
||||
);
|
||||
}).toList();
|
||||
|
||||
// Build geofence polygon overlay if available
|
||||
// Geofence polygon overlay
|
||||
final polygonLayers = <PolygonLayer>[];
|
||||
if (geofenceConfig != null && geofenceConfig.hasPolygon) {
|
||||
final List<LatLng> 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<WhereaboutsScreen> {
|
||||
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<WhereaboutsScreen> {
|
||||
),
|
||||
...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<LivePosition> positions,
|
||||
Map<String, Profile> 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<Profile> profiles;
|
||||
final Map<String, LivePosition> positionByUser;
|
||||
final Set<String> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user