311 lines
10 KiB
Dart
311 lines
10 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(
|
|
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),
|
|
),
|
|
),
|
|
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,
|
|
) {
|
|
// Only pin users who are in-premise (privacy: don't reveal off-site locations).
|
|
final inPremisePositions = positions.where((pos) => pos.inPremise).toList();
|
|
|
|
final markers = inPremisePositions.map((pos) {
|
|
final profile = profileById[pos.userId];
|
|
final name = profile?.fullName ?? 'Unknown';
|
|
final pinColor = _roleColor(profile?.role);
|
|
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: pinColor),
|
|
],
|
|
),
|
|
);
|
|
}).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();
|
|
|
|
// 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();
|
|
|
|
if (legendEntries.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Container(
|
|
constraints: const BoxConstraints(maxHeight: 220),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Role color legend header
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
|
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 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,
|
|
),
|
|
),
|
|
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),
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
}
|