tasq/lib/screens/whereabouts/whereabouts_screen.dart

230 lines
7.1 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/live_position.dart';
import '../../models/profile.dart';
import '../../providers/profile_provider.dart';
import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../utils/app_time.dart';
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 Map<String, Profile> profileById = {
for (final p in profilesAsync.valueOrNull ?? []) p.id: p,
};
return ResponsiveBody(
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),
),
),
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')),
),
),
// Staff list below the map
positionsAsync.when(
data: (positions) =>
_buildStaffList(context, positions, profileById),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
),
],
),
);
}
Widget _buildMap(
BuildContext context,
List<LivePosition> positions,
Map<String, Profile> profileById,
GeofenceConfig? geofenceConfig,
) {
final markers = positions.map((pos) {
final name = profileById[pos.userId]?.fullName ?? 'Unknown';
final inPremise = pos.inPremise;
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: Text(
name.split(' ').first,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis,
),
),
Icon(
Icons.location_pin,
size: 28,
color: inPremise ? Colors.green : Colors.orange,
),
],
),
);
}).toList();
// Build geofence polygon overlay if available
final polygonLayers = <PolygonLayer>[];
if (geofenceConfig != null && geofenceConfig.hasPolygon) {
final List<LatLng> points = geofenceConfig.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: positions.isNotEmpty
? LatLng(positions.first.lat, positions.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),
],
);
}
Widget _buildStaffList(
BuildContext context,
List<LivePosition> positions,
Map<String, Profile> profileById,
) {
if (positions.isEmpty) return const SizedBox.shrink();
return Container(
constraints: const BoxConstraints(maxHeight: 180),
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: positions.length,
itemBuilder: (context, index) {
final pos = positions[index];
final p = profileById[pos.userId];
final name = p?.fullName ?? 'Unknown';
final role = p?.role ?? '-';
final timeAgo = _timeAgo(pos.updatedAt);
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: pos.inPremise
? Colors.green.shade100
: Colors.orange.shade100,
child: Icon(
pos.inPremise ? Icons.check : Icons.location_off,
size: 16,
color: pos.inPremise ? Colors.green : Colors.orange,
),
),
title: Text(name),
subtitle: Text('${_roleLabel(role)} · $timeAgo'),
trailing: Text(
pos.inPremise ? 'In premise' : 'Off-site',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: pos.inPremise ? Colors.green : Colors.orange,
),
),
onTap: () => _mapController.move(LatLng(pos.lat, pos.lng), 17.0),
);
},
),
);
}
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';
}
}
}