import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; 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', }; } /// 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'; if (diff.inHours < 24) return '${diff.inHours}h ago'; return '${diff.inDays}d ago'; } bool _isStale(DateTime dt) { return AppTime.now().difference(dt) > _staleDuration; } // --------------------------------------------------------------------------- // Whereabouts Screen // --------------------------------------------------------------------------- class WhereaboutsScreen extends ConsumerStatefulWidget { const WhereaboutsScreen({super.key}); @override ConsumerState createState() => _WhereaboutsScreenState(); } class _WhereaboutsScreenState extends ConsumerState { final _mapController = MapController(); @override Widget build(BuildContext context) { 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 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, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Text( 'Whereabouts', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), ), // Map Expanded( 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, ), ), ), ), 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), ], ), ); } } // --------------------------------------------------------------------------- // Map widget // --------------------------------------------------------------------------- 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) { // 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 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: 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, ), ), Icon(Icons.location_pin, size: 28, color: pinColor), ], ), ), ); }).toList(); // Geofence polygon overlay final polygonLayers = []; final geoConfig = geofenceConfig; if (geoConfig != null && geoConfig.hasPolygon) { final points = geoConfig.polygon! .map((p) => LatLng(p.lat, p.lng)) .toList(); if (points.isNotEmpty) { polygonLayers.add( PolygonLayer( polygons: [ Polygon( points: points, color: Colors.blue.withValues(alpha: 0.1), borderColor: Colors.blue, borderStrokeWidth: 2.0, ), ], ), ); } } // Default center: CRMC Cotabato City area const defaultCenter = LatLng(7.2046, 124.2460); return FlutterMap( mapController: mapController, options: MapOptions( initialCenter: inPremise.isNotEmpty ? LatLng(inPremise.first.lat, inPremise.first.lng) : defaultCenter, initialZoom: 16.0, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.tasq.app', ), ...polygonLayers, MarkerLayer(markers: markers), // OSM attribution (required by OpenStreetMap tile usage policy). const RichAttributionWidget( alignment: AttributionAlignment.bottomLeft, attributions: [TextSourceAttribution('OpenStreetMap contributors')], ), ], ); } } // --------------------------------------------------------------------------- // Staff Legend Panel — sits outside the map // --------------------------------------------------------------------------- class _StaffLegendPanel extends StatelessWidget { const _StaffLegendPanel({ required this.profiles, required this.positionByUser, required this.activeCheckInUsers, required this.mapController, }); 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( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: cs.surfaceContainerLow, borderRadius: BorderRadius.circular(surfaces.cardRadius), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header with role badges Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row( children: [ Text( '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: 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, ); }, ), ), ], ), ); } } // --------------------------------------------------------------------------- // 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, ), ), ], ); } } // --------------------------------------------------------------------------- // 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; final isStale = hasPosition && _isStale(position!.updatedAt); // Determine display state final bool isActive = hasPosition && isInPremise && !isStale; 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) { final timeLabel = _timeAgo(position!.updatedAt); statusText = isInPremise ? '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; } else { 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 \u00b7 ${_timeAgo(position!.updatedAt)}'; statusColor = Colors.grey; } else { statusText = 'No location data'; statusColor = Colors.grey; } 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) { 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, ), ), ); } }