tasq/lib/screens/whereabouts/whereabouts_screen.dart

525 lines
17 KiB
Dart

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/app_page_header.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, ColorScheme cs) {
return switch (role) {
'admin' => cs.primary,
'it_staff' => cs.tertiary,
'dispatcher' => cs.secondary,
_ => cs.outline,
};
}
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<WhereaboutsScreen> createState() => _WhereaboutsScreenState();
}
class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
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 <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 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,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Whereabouts',
subtitle: 'Live staff positions and active check-ins',
),
// 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<LivePosition> positions;
final Map<String, Profile> 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 cs = Theme.of(context).colorScheme;
final pinColor = stale ? cs.outlineVariant : _roleColor(profile?.role, cs);
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 = <PolygonLayer>[];
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<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(
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, cs);
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
: cs.outlineVariant;
// 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,
),
),
);
}
}