From 984ce99c72ec3406274c7cbdb093f00a93246911 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 29 Jun 2026 16:57:29 +0800 Subject: [PATCH] Implement helper dashboard, providers, assignment check-in, and routing in mobile app --- lib/src/data/auth/auth_repository.dart | 4 +- lib/src/data/dop/dop_repository.dart | 6 + .../data/driver/driver_location_service.dart | 108 +++ lib/src/data/driver/driver_models.dart | 264 ++++++ lib/src/data/driver/driver_providers.dart | 53 ++ lib/src/data/driver/driver_repository.dart | 165 ++++ lib/src/data/helper/helper_models.dart | 28 + lib/src/data/helper/helper_providers.dart | 55 ++ lib/src/data/helper/helper_repository.dart | 52 ++ lib/src/data/scan/scan_repository.dart | 30 +- lib/src/router.dart | 25 +- lib/src/theme/app_theme.dart | 25 +- lib/src/ui/driver/driver_home_screen.dart | 302 +++++++ lib/src/ui/driver/trip_detail_screen.dart | 803 ++++++++++++++++++ lib/src/ui/helper/helper_home_screen.dart | 437 ++++++++++ lib/src/ui/home/home_screen.dart | 605 +++++++++++-- pubspec.lock | 24 +- pubspec.yaml | 1 + 18 files changed, 2914 insertions(+), 73 deletions(-) create mode 100644 lib/src/data/driver/driver_location_service.dart create mode 100644 lib/src/data/driver/driver_models.dart create mode 100644 lib/src/data/driver/driver_providers.dart create mode 100644 lib/src/data/driver/driver_repository.dart create mode 100644 lib/src/data/helper/helper_models.dart create mode 100644 lib/src/data/helper/helper_providers.dart create mode 100644 lib/src/data/helper/helper_repository.dart create mode 100644 lib/src/ui/driver/driver_home_screen.dart create mode 100644 lib/src/ui/driver/trip_detail_screen.dart create mode 100644 lib/src/ui/helper/helper_home_screen.dart diff --git a/lib/src/data/auth/auth_repository.dart b/lib/src/data/auth/auth_repository.dart index 00caefa..f6b3fb3 100644 --- a/lib/src/data/auth/auth_repository.dart +++ b/lib/src/data/auth/auth_repository.dart @@ -61,8 +61,8 @@ class AuthRepository { } final data = body['data'] as Map; final user = ScannerUser.fromJson(data['user'] as Map); - if (user.role != 'scanner' && user.role != 'admin') { - throw ApiException(403, 'This app is for scanner staff only.'); + if (user.role != 'scanner' && user.role != 'admin' && user.role != 'driver') { + throw ApiException(403, 'This app is for driver and scanner staff only.'); } await _storage.writeToken(data['token'] as String); return user; diff --git a/lib/src/data/dop/dop_repository.dart b/lib/src/data/dop/dop_repository.dart index a3c3ba7..3728458 100644 --- a/lib/src/data/dop/dop_repository.dart +++ b/lib/src/data/dop/dop_repository.dart @@ -12,6 +12,8 @@ class DopOption { required this.name, required this.code, this.distanceMeters, + this.lat, + this.lng, }); factory DopOption.fromJson(Map j) => DopOption( @@ -22,6 +24,8 @@ class DopOption { name: j['name'] as String? ?? '—', code: j['code'] as String? ?? '', distanceMeters: (j['distance_meters'] as num?)?.toDouble(), + lat: (j['coordinates']?['lat'] as num?)?.toDouble(), + lng: (j['coordinates']?['lng'] as num?)?.toDouble(), ); final int id; @@ -29,6 +33,8 @@ class DopOption { final String name; final String code; final double? distanceMeters; + final double? lat; + final double? lng; } class DopRepository { diff --git a/lib/src/data/driver/driver_location_service.dart b/lib/src/data/driver/driver_location_service.dart new file mode 100644 index 0000000..50980af --- /dev/null +++ b/lib/src/data/driver/driver_location_service.dart @@ -0,0 +1,108 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'driver_repository.dart'; + +class LocationService { + LocationService(this._repository); + final DriverRepository _repository; + + StreamSubscription? _positionStreamSub; + String? _activeTruckUuid; + Timer? _telemetryTimer; + Position? _lastPosition; + + // Checks and requests location permissions + Future requestPermissions() async { + final status = await Permission.locationWhenInUse.request(); + if (status.isGranted) { + // Background permission is optional/best-effort for now + await Permission.locationAlways.request(); + return true; + } + return false; + } + + // Starts tracking coordinates and posting them to the telemetry endpoint + Future startTracking(String truckUuid) async { + if (_positionStreamSub != null && _activeTruckUuid == truckUuid) return; + + await stopTracking(); + _activeTruckUuid = truckUuid; + + final hasPerm = await requestPermissions(); + if (!hasPerm) { + debugPrint('Location permission denied. Cannot start tracking telemetry.'); + return; + } + + // Set geolocator settings + const settings = LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 10, // Update every 10 meters + ); + + // Listen to live coordinates + _positionStreamSub = Geolocator.getPositionStream(locationSettings: settings).listen( + (position) { + _lastPosition = position; + }, + onError: (err) { + debugPrint('Geolocator stream error: $err'); + }, + ); + + // Send telemetry to API periodically (e.g. every 15 seconds) + _telemetryTimer = Timer.periodic(const Duration(seconds: 15), (timer) async { + final pos = _lastPosition; + final truck = _activeTruckUuid; + if (pos != null && truck != null) { + try { + await _repository.updateLocation( + truck, + lat: pos.latitude, + lng: pos.longitude, + ); + debugPrint('Posted telemetry: Lat: ${pos.latitude}, Lng: ${pos.longitude}'); + } catch (e) { + debugPrint('Failed to post location telemetry: $e'); + } + } + }); + } + + // Stops tracking stream and timer + Future stopTracking() async { + await _positionStreamSub?.cancel(); + _positionStreamSub = null; + _telemetryTimer?.cancel(); + _telemetryTimer = null; + _activeTruckUuid = null; + _lastPosition = null; + } + + // Get current position once (e.g., for actions) + Future getCurrentPosition() async { + try { + final hasPermission = await requestPermissions(); + if (!hasPermission) return null; + return await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings(accuracy: LocationAccuracy.high), + ); + } catch (e) { + debugPrint('Failed to get current position: $e'); + return null; + } + } +} + +final locationServiceProvider = Provider((ref) { + final service = LocationService(ref.watch(driverRepositoryProvider)); + ref.onDispose(() { + service.stopTracking(); + }); + return service; +}); diff --git a/lib/src/data/driver/driver_models.dart b/lib/src/data/driver/driver_models.dart new file mode 100644 index 0000000..457bbae --- /dev/null +++ b/lib/src/data/driver/driver_models.dart @@ -0,0 +1,264 @@ +class LatLngLiteral { + LatLngLiteral({required this.lat, required this.lng}); + factory LatLngLiteral.fromJson(Map j) => LatLngLiteral( + lat: (j['lat'] as num).toDouble(), + lng: (j['lng'] as num).toDouble(), + ); + final double lat; + final double lng; +} + +class Truck { + Truck({ + required this.id, + required this.dbId, + required this.plateNumber, + this.model, + required this.capacityKg, + required this.status, + }); + + factory Truck.fromJson(Map j) => Truck( + id: j['id'] as String, + dbId: j['db_id'] as int, + plateNumber: j['plate_number'] as String? ?? '', + model: j['model'] as String?, + capacityKg: j['capacity_kg'] as int? ?? 0, + status: j['status'] as String? ?? '', + ); + + final String id; + final int dbId; + final String plateNumber; + final String? model; + final int capacityKg; + final String status; +} + +class Dumpsite { + Dumpsite({ + required this.id, + required this.dbId, + required this.name, + required this.code, + this.addressLine, + this.coordinates, + this.boundaryPolygon, + this.capacityTons, + required this.status, + }); + + factory Dumpsite.fromJson(Map j) => Dumpsite( + id: j['id'] as String, + dbId: j['db_id'] as int, + name: j['name'] as String? ?? '', + code: j['code'] as String? ?? '', + addressLine: j['address_line'] as String?, + coordinates: j['coordinates'] != null + ? LatLngLiteral.fromJson(j['coordinates'] as Map) + : null, + boundaryPolygon: j['boundary_polygon'] != null + ? (j['boundary_polygon'] as List) + .map((p) => LatLngLiteral.fromJson(p as Map)) + .toList() + : null, + capacityTons: j['capacity_tons'] as int?, + status: j['status'] as String? ?? '', + ); + + final String id; + final int dbId; + final String name; + final String code; + final String? addressLine; + final LatLngLiteral? coordinates; + final List? boundaryPolygon; + final int? capacityTons; + final String status; +} + +class RouteInfo { + RouteInfo({ + required this.id, + required this.dbId, + required this.name, + required this.code, + required this.status, + required this.estimatedDurationMinutes, + required this.totalDistanceKm, + this.notes, + }); + + factory RouteInfo.fromJson(Map j) => RouteInfo( + id: j['id'] as String, + dbId: j['db_id'] as int, + name: j['name'] as String? ?? '', + code: j['code'] as String? ?? '', + status: j['status'] as String? ?? '', + estimatedDurationMinutes: j['estimated_duration_minutes'] as int? ?? 0, + totalDistanceKm: (j['total_distance_km'] as num?)?.toDouble() ?? 0.0, + notes: j['notes'] as String?, + ); + + final String id; + final int dbId; + final String name; + final String code; + final String status; + final int estimatedDurationMinutes; + final double totalDistanceKm; + final String? notes; +} + +class CollectionTeam { + CollectionTeam({ + required this.id, + required this.dbId, + required this.name, + required this.status, + this.driverName, + this.scannerName, + this.truck, + }); + + factory CollectionTeam.fromJson(Map j) => CollectionTeam( + id: j['id'] as String, + dbId: j['db_id'] as int, + name: j['name'] as String? ?? '', + status: j['status'] as String? ?? '', + driverName: j['driver']?['full_name'] as String?, + scannerName: j['scanner']?['full_name'] as String?, + truck: j['truck'] != null ? Truck.fromJson(j['truck'] as Map) : null, + ); + + final String id; + final int dbId; + final String name; + final String status; + final String? driverName; + final String? scannerName; + final Truck? truck; +} + +class TripStop { + TripStop({ + required this.id, + required this.sequence, + required this.status, + this.scheduledArrival, + this.actualArrival, + this.actualDeparture, + required this.totalScans, + required this.estimatedLoadAddedKg, + this.skipReason, + required this.dropOffPointName, + required this.dropOffPointUuid, + this.dropOffPointCoordinates, + }); + + factory TripStop.fromJson(Map j) { + final dop = j['drop_off_point'] as Map? ?? {}; + return TripStop( + id: j['id'] as int, + sequence: j['sequence'] as int? ?? 0, + status: j['status'] as String? ?? 'pending', + scheduledArrival: j['scheduled_arrival'] != null + ? DateTime.parse(j['scheduled_arrival'] as String) + : null, + actualArrival: j['actual_arrival'] != null + ? DateTime.parse(j['actual_arrival'] as String) + : null, + actualDeparture: j['actual_departure'] != null + ? DateTime.parse(j['actual_departure'] as String) + : null, + totalScans: j['total_scans'] as int? ?? 0, + estimatedLoadAddedKg: j['estimated_load_added_kg'] as int? ?? 0, + skipReason: j['skip_reason'] as String?, + dropOffPointName: dop['name'] as String? ?? '', + dropOffPointUuid: dop['id'] as String? ?? '', + dropOffPointCoordinates: dop['coordinates'] != null + ? LatLngLiteral.fromJson(dop['coordinates'] as Map) + : null, + ); + } + + final int id; + final int sequence; + final String status; + final DateTime? scheduledArrival; + final DateTime? actualArrival; + final DateTime? actualDeparture; + final int totalScans; + final int estimatedLoadAddedKg; + final String? skipReason; + final String dropOffPointName; + final String dropOffPointUuid; + final LatLngLiteral? dropOffPointCoordinates; +} + +class Trip { + Trip({ + required this.id, + required this.tripNumber, + required this.status, + required this.scheduledDate, + required this.scheduledStartTime, + this.actualStartTime, + this.actualEndTime, + this.dumpsiteArrivalTime, + this.dumpsiteDepartureTime, + required this.totalLoadKg, + this.notes, + this.route, + this.team, + this.truck, + this.dumpsite, + required this.stops, + }); + + factory Trip.fromJson(Map j) => Trip( + id: j['id'] as String, + tripNumber: j['trip_number'] as String? ?? '', + status: j['status'] as String? ?? 'scheduled', + scheduledDate: j['scheduled_date'] as String? ?? '', + scheduledStartTime: j['scheduled_start_time'] as String? ?? '', + actualStartTime: j['actual_start_time'] != null + ? DateTime.parse(j['actual_start_time'] as String) + : null, + actualEndTime: j['actual_end_time'] != null + ? DateTime.parse(j['actual_end_time'] as String) + : null, + dumpsiteArrivalTime: j['dumpsite_arrival_time'] != null + ? DateTime.parse(j['dumpsite_arrival_time'] as String) + : null, + dumpsiteDepartureTime: j['dumpsite_departure_time'] != null + ? DateTime.parse(j['dumpsite_departure_time'] as String) + : null, + totalLoadKg: j['total_load_kg'] as int? ?? 0, + notes: j['notes'] as String?, + route: j['route'] != null ? RouteInfo.fromJson(j['route'] as Map) : null, + team: j['team'] != null ? CollectionTeam.fromJson(j['team'] as Map) : null, + truck: j['truck'] != null ? Truck.fromJson(j['truck'] as Map) : null, + dumpsite: j['dumpsite'] != null ? Dumpsite.fromJson(j['dumpsite'] as Map) : null, + stops: j['stops'] != null + ? (j['stops'] as List).map((s) => TripStop.fromJson(s as Map)).toList() + : [], + ); + + final String id; + final String tripNumber; + final String status; + final String scheduledDate; + final String scheduledStartTime; + final DateTime? actualStartTime; + final DateTime? actualEndTime; + final DateTime? dumpsiteArrivalTime; + final DateTime? dumpsiteDepartureTime; + final int totalLoadKg; + final String? notes; + final RouteInfo? route; + final CollectionTeam? team; + final Truck? truck; + final Dumpsite? dumpsite; + final List stops; +} diff --git a/lib/src/data/driver/driver_providers.dart b/lib/src/data/driver/driver_providers.dart new file mode 100644 index 0000000..c26c8c1 --- /dev/null +++ b/lib/src/data/driver/driver_providers.dart @@ -0,0 +1,53 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'driver_location_service.dart'; +import 'driver_models.dart'; +import 'driver_repository.dart'; + +// Async provider for fetching list of trips +final driverTripsProvider = FutureProvider.autoDispose>((ref) async { + return ref.watch(driverRepositoryProvider).fetchTrips(); +}); + +// Async provider for fetching single trip details +final driverTripDetailsProvider = FutureProvider.autoDispose.family((ref, uuid) async { + return ref.watch(driverRepositoryProvider).fetchTrip(uuid); +}); + +// StateNotifier to track the active trip and manage location tracking lifecycle +class ActiveTripNotifier extends StateNotifier { + ActiveTripNotifier(this._locationService, this._repository) : super(null); + final LocationService _locationService; + final DriverRepository _repository; + + void setActiveTrip(Trip? trip) { + state = trip; + if (trip != null && trip.status == 'in_progress' && trip.truck != null) { + _locationService.startTracking(trip.truck!.id); + } else { + _locationService.stopTracking(); + } + } + + Future startTrip(String uuid, double? lat, double? lng) async { + final updatedTrip = await _repository.startTrip(uuid, lat: lat, lng: lng); + setActiveTrip(updatedTrip); + } + + Future completeTrip(String uuid, double? lat, double? lng) async { + await _repository.completeTrip(uuid, lat: lat, lng: lng); + setActiveTrip(null); + } + + Future arriveDumpsite(String uuid, double lat, double lng, {bool overrideGeofence = false}) async { + final updatedTrip = await _repository.arriveDumpsite(uuid, lat: lat, lng: lng, overrideGeofence: overrideGeofence); + setActiveTrip(updatedTrip); + } +} + +final activeTripProvider = StateNotifierProvider((ref) { + return ActiveTripNotifier( + ref.watch(locationServiceProvider), + ref.watch(driverRepositoryProvider), + ); +}); diff --git a/lib/src/data/driver/driver_repository.dart b/lib/src/data/driver/driver_repository.dart new file mode 100644 index 0000000..e8dc635 --- /dev/null +++ b/lib/src/data/driver/driver_repository.dart @@ -0,0 +1,165 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/api_client.dart'; +import 'driver_models.dart'; + +class DriverRepository { + DriverRepository(this._dio); + final Dio _dio; + + Future> fetchTrips() async { + final res = await _dio.get('/driver/trips'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + final list = body['data'] as List; + return list.map((t) => Trip.fromJson(t as Map)).toList(); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load trips'); + } + + Future fetchTrip(String uuid) async { + final res = await _dio.get('/driver/trips/$uuid'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + return Trip.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load trip details'); + } + + Future startTrip(String uuid, {double? lat, double? lng}) async { + final res = await _dio.post('/driver/trips/$uuid/start', data: { + 'lat':? lat, + 'lng':? lng, + }); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + return Trip.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to start trip'); + } + + Future arriveAtStop(String tripUuid, int stopId, {required double lat, required double lng}) async { + final res = await _dio.post('/driver/trips/$tripUuid/stops/$stopId/arrive', data: { + 'lat': lat, + 'lng': lng, + }); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + final data = body['data'] as Map; + return TripStop.fromJson(data['stop'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to record arrival'); + } + + Future departStop(String tripUuid, int stopId, {required double lat, required double lng}) async { + final res = await _dio.post('/driver/trips/$tripUuid/stops/$stopId/depart', data: { + 'lat': lat, + 'lng': lng, + }); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + final data = body['data'] as Map; + return TripStop.fromJson(data['stop'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to record departure'); + } + + Future skipStop(String tripUuid, int stopId, {required String reason}) async { + final res = await _dio.post('/driver/trips/$tripUuid/stops/$stopId/skip', data: { + 'reason': reason, + }); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + final data = body['data'] as Map; + return TripStop.fromJson(data['stop'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to skip stop'); + } + + Future> reportIncident( + String tripUuid, { + required String kind, + required String notes, + double? lat, + double? lng, + }) async { + final res = await _dio.post('/driver/trips/$tripUuid/incident', data: { + 'kind': kind, + 'notes': notes, + 'lat':? lat, + 'lng':? lng, + }); + final body = res.data as Map; + if ((res.statusCode == 200 || res.statusCode == 201) && body['success'] == true) { + return body['data'] as Map; + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to log incident'); + } + + Future arriveDumpsite(String tripUuid, {required double lat, required double lng, bool overrideGeofence = false}) async { + final res = await _dio.post('/driver/trips/$tripUuid/arrive-dumpsite', data: { + 'lat': lat, + 'lng': lng, + if (overrideGeofence) 'override_geofence': true, + }); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + return Trip.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to record dumpsite arrival'); + } + + Future> releaseLoad( + String tripUuid, { + required int weightKg, + String? gatePassNumber, + String? dumpsiteAttendantName, + Map? wasteTypeBreakdown, + double? lat, + double? lng, + String? notes, + }) async { + final res = await _dio.post('/driver/trips/$tripUuid/release-load', data: { + 'weight_kg': weightKg, + 'gate_pass_number':? gatePassNumber, + 'dumpsite_attendant_name':? dumpsiteAttendantName, + 'waste_type_breakdown':? wasteTypeBreakdown, + 'lat':? lat, + 'lng':? lng, + 'notes':? notes, + }); + final body = res.data as Map; + if ((res.statusCode == 200 || res.statusCode == 201) && body['success'] == true) { + return body['data'] as Map; + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to release load'); + } + + Future completeTrip(String tripUuid, {double? lat, double? lng}) async { + final res = await _dio.post('/driver/trips/$tripUuid/complete', data: { + 'lat':? lat, + 'lng':? lng, + }); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + return Trip.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to complete trip'); + } + + Future updateLocation(String truckUuid, {required double lat, required double lng}) async { + final res = await _dio.post('/driver/trucks/$truckUuid/location', data: { + 'lat': lat, + 'lng': lng, + }); + final body = res.data as Map; + if (res.statusCode != 200 || body['success'] != true) { + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to update truck location'); + } + } +} + +final driverRepositoryProvider = Provider((ref) { + return DriverRepository(ref.watch(apiClientProvider)); +}); diff --git a/lib/src/data/helper/helper_models.dart b/lib/src/data/helper/helper_models.dart new file mode 100644 index 0000000..be598fd --- /dev/null +++ b/lib/src/data/helper/helper_models.dart @@ -0,0 +1,28 @@ +import '../driver/driver_models.dart'; + +class HelperAssignment { + HelperAssignment({ + required this.id, + required this.roleInTeam, + required this.status, + required this.assignedFrom, + this.assignedUntil, + required this.team, + }); + + factory HelperAssignment.fromJson(Map j) => HelperAssignment( + id: j['id'] as int, + roleInTeam: j['role_in_team'] as String? ?? 'helper', + status: j['status'] as String? ?? 'pending', + assignedFrom: j['assigned_from'] as String? ?? '', + assignedUntil: j['assigned_until'] as String?, + team: CollectionTeam.fromJson(j['team'] as Map), + ); + + final int id; + final String roleInTeam; + final String status; + final String assignedFrom; + final String? assignedUntil; + final CollectionTeam team; +} diff --git a/lib/src/data/helper/helper_providers.dart b/lib/src/data/helper/helper_providers.dart new file mode 100644 index 0000000..32eee82 --- /dev/null +++ b/lib/src/data/helper/helper_providers.dart @@ -0,0 +1,55 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'helper_models.dart'; +import 'helper_repository.dart'; + +// FutureProvider for fetching today's active assignment +final helperAssignmentProvider = FutureProvider.autoDispose((ref) async { + return ref.watch(helperRepositoryProvider).fetchAssignment(); +}); + +// FutureProvider for fetching helper assignment history +final helperHistoryProvider = FutureProvider.autoDispose>((ref) async { + return ref.watch(helperRepositoryProvider).fetchHistory(); +}); + +// StateNotifier to manage current assignment and actions +class CurrentAssignmentNotifier extends StateNotifier> { + CurrentAssignmentNotifier(this._repository) : super(const AsyncValue.loading()) { + refresh(); + } + + final HelperRepository _repository; + + Future refresh() async { + state = const AsyncValue.loading(); + try { + final assignment = await _repository.fetchAssignment(); + state = AsyncValue.data(assignment); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future accept() async { + try { + final updated = await _repository.acceptAssignment(); + state = AsyncValue.data(updated); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } + + Future reject() async { + try { + final updated = await _repository.rejectAssignment(); + state = AsyncValue.data(updated); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } + } +} + +final currentAssignmentProvider = StateNotifierProvider.autoDispose>((ref) { + return CurrentAssignmentNotifier(ref.watch(helperRepositoryProvider)); +}); diff --git a/lib/src/data/helper/helper_repository.dart b/lib/src/data/helper/helper_repository.dart new file mode 100644 index 0000000..cfa00fb --- /dev/null +++ b/lib/src/data/helper/helper_repository.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/api_client.dart'; +import 'helper_models.dart'; + +class HelperRepository { + HelperRepository(this._dio); + final Dio _dio; + + Future fetchAssignment() async { + final res = await _dio.get('/helper/assignment'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + if (body['data'] == null) return null; + return HelperAssignment.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load assignment'); + } + + Future acceptAssignment() async { + final res = await _dio.post('/helper/assignment/accept'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + return HelperAssignment.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to accept assignment'); + } + + Future rejectAssignment() async { + final res = await _dio.post('/helper/assignment/reject'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + return HelperAssignment.fromJson(body['data'] as Map); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to reject assignment'); + } + + Future> fetchHistory() async { + final res = await _dio.get('/helper/history'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + final list = body['data'] as List; + return list.map((a) => HelperAssignment.fromJson(a as Map)).toList(); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load history'); + } +} + +final helperRepositoryProvider = Provider((ref) { + return HelperRepository(ref.watch(apiClientProvider)); +}); diff --git a/lib/src/data/scan/scan_repository.dart b/lib/src/data/scan/scan_repository.dart index 0dbf144..8851e29 100644 --- a/lib/src/data/scan/scan_repository.dart +++ b/lib/src/data/scan/scan_repository.dart @@ -3,10 +3,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../api/api_client.dart'; +import '../driver/driver_models.dart'; import 'pending_scan.dart'; import 'scan_models.dart'; import 'scan_queue.dart'; +class ScannerTeamDetails { + ScannerTeamDetails({required this.team, this.activeTrip}); + final CollectionTeam team; + final Trip? activeTrip; +} + class ScanRepository { ScanRepository(this._dio, this._queue, this._ref); final Dio _dio; @@ -35,8 +42,8 @@ class ScanRepository { 'drop_off_point_id': dropOffPointId, 'lat': pos.latitude, 'lng': pos.longitude, - if (tripUuid != null) 'trip_id': tripUuid, - if (tripStopId != null) 'trip_stop_id': tripStopId, + 'trip_id':? tripUuid, + 'trip_stop_id':? tripStopId, }); final body = res.data as Map; @@ -90,6 +97,21 @@ class ScanRepository { return null; } } + + Future fetchTeamDetails() async { + final res = await _dio.get('/scanner/team-details'); + final body = res.data as Map; + if (res.statusCode == 200 && body['success'] == true) { + final data = body['data'] as Map; + return ScannerTeamDetails( + team: CollectionTeam.fromJson(data['team'] as Map), + activeTrip: data['active_trip'] != null + ? Trip.fromJson(data['active_trip'] as Map) + : null, + ); + } + throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load team details'); + } } final scanRepositoryProvider = Provider((ref) { @@ -100,6 +122,10 @@ final scanRepositoryProvider = Provider((ref) { ); }); +final scannerTeamDetailsProvider = FutureProvider.autoDispose((ref) async { + return ref.watch(scanRepositoryProvider).fetchTeamDetails(); +}); + /// Recent scans — in-memory ring buffer. Cleared on sign-out. class RecentScansController extends StateNotifier> { RecentScansController() : super([]); diff --git a/lib/src/router.dart b/lib/src/router.dart index 1fee7a1..ffcb9a5 100644 --- a/lib/src/router.dart +++ b/lib/src/router.dart @@ -4,6 +4,9 @@ import 'package:go_router/go_router.dart'; import 'data/auth/auth_controller.dart'; import 'ui/dop_picker/dop_picker_screen.dart'; +import 'ui/driver/driver_home_screen.dart'; +import 'ui/driver/trip_detail_screen.dart'; +import 'ui/helper/helper_home_screen.dart'; import 'ui/home/home_screen.dart'; import 'ui/login/login_screen.dart'; import 'ui/scan/scan_camera_screen.dart'; @@ -29,11 +32,25 @@ final routerProvider = Provider((ref) { } if (auth is Authenticated) { + if (auth.user.role == 'driver') { + if (loc == '/login' || loc == '/tenant' || loc == '/' || loc == '/dop' || loc == '/home' || loc == '/scan') { + return '/driver/home'; + } + return null; + } + + if (auth.user.role == 'helper') { + if (loc == '/login' || loc == '/tenant' || loc == '/' || loc == '/dop' || loc == '/home' || loc == '/scan' || loc.startsWith('/driver/')) { + return '/helper/home'; + } + return null; + } + // Pre-shift gate: must pick a DOP before scanning. if (!auth.hasActiveDop) { return loc == '/dop' ? null : '/dop'; } - if (loc == '/login' || loc == '/tenant' || loc == '/' || loc == '/dop') { + if (loc == '/login' || loc == '/tenant' || loc == '/' || loc == '/dop' || loc.startsWith('/driver/') || loc == '/helper/home') { return '/home'; } return null; @@ -48,6 +65,12 @@ final routerProvider = Provider((ref) { GoRoute(path: '/dop', builder: (c, s) => const DopPickerScreen()), GoRoute(path: '/home', builder: (c, s) => const HomeScreen()), GoRoute(path: '/scan', builder: (c, s) => const ScanCameraScreen()), + GoRoute(path: '/driver/home', builder: (c, s) => const DriverHomeScreen()), + GoRoute(path: '/helper/home', builder: (c, s) => const HelperHomeScreen()), + GoRoute( + path: '/driver/trips/:uuid', + builder: (c, s) => TripDetailScreen(uuid: s.pathParameters['uuid'] ?? ''), + ), ], ); }); diff --git a/lib/src/theme/app_theme.dart b/lib/src/theme/app_theme.dart index 17ab867..fe84801 100644 --- a/lib/src/theme/app_theme.dart +++ b/lib/src/theme/app_theme.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; class VerdeColors { - static const verde50 = Color(0xFFF0FDF4); - static const verde100 = Color(0xFFDCFCE7); - static const verde200 = Color(0xFFBBF7D0); - static const verde300 = Color(0xFF86EFAC); - static const verde400 = Color(0xFF4ADE80); - static const verde500 = Color(0xFF22C55E); - static const verde600 = Color(0xFF16A34A); - static const verde700 = Color(0xFF15803D); - static const verde800 = Color(0xFF166534); - static const verde900 = Color(0xFF14532D); + static const verde50 = Color(0xFFF0FAF1); + static const verde100 = Color(0xFFDCF3DF); + static const verde200 = Color(0xFFBCE6C2); + static const verde300 = Color(0xFF8DD297); + static const verde400 = Color(0xFF5DB86B); + static const verde500 = Color(0xFF3EA052); + static const verde600 = Color(0xFF2D8341); + static const verde700 = Color(0xFF266836); + static const verde800 = Color(0xFF22532F); + static const verde900 = Color(0xFF1D4528); + static const verde950 = Color(0xFF0D2614); } ThemeData buildAppTheme() { @@ -27,6 +29,7 @@ ThemeData buildAppTheme() { colorScheme: colorScheme, useMaterial3: true, scaffoldBackgroundColor: const Color(0xFFFAFAFA), + textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), ); return base.copyWith( @@ -50,7 +53,7 @@ ThemeData buildAppTheme() { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: VerdeColors.verde500, width: 1.5), + borderSide: const BorderSide(color: VerdeColors.verde500, width: 1.5), ), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), ), diff --git a/lib/src/ui/driver/driver_home_screen.dart b/lib/src/ui/driver/driver_home_screen.dart new file mode 100644 index 0000000..73c0b0d --- /dev/null +++ b/lib/src/ui/driver/driver_home_screen.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/auth/auth_controller.dart'; +import '../../data/driver/driver_models.dart'; +import '../../data/driver/driver_providers.dart'; +import '../../theme/app_theme.dart'; + +class DriverHomeScreen extends ConsumerWidget { + const DriverHomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authControllerProvider); + if (auth is! Authenticated) return const SizedBox.shrink(); + + final tripsFuture = ref.watch(driverTripsProvider); + final activeTrip = ref.watch(activeTripProvider); + + return Scaffold( + appBar: AppBar( + titleSpacing: 16, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Hi, ${auth.user.fullName.split(' ').first}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + Text('${auth.tenantName} · Driver', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF737373))), + ], + ), + actions: [ + IconButton( + tooltip: 'Sign out', + icon: const Icon(Icons.logout_rounded), + onPressed: () => ref.read(authControllerProvider.notifier).logout(), + ), + ], + ), + body: SafeArea( + child: RefreshIndicator( + onRefresh: () => ref.refresh(driverTripsProvider.future), + child: tripsFuture.when( + data: (trips) { + // Automatically sync the activeTrip state with the first in_progress trip from server if not set + if (activeTrip == null) { + final inProgress = trips.where((t) => t.status == 'in_progress'); + if (inProgress.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(activeTripProvider.notifier).setActiveTrip(inProgress.first); + }); + } + } + + if (trips.isEmpty) { + return const _EmptyTrips(); + } + + final inProgressTrips = trips.where((t) => t.status == 'in_progress').toList(); + final scheduledTrips = trips.where((t) => t.status == 'scheduled').toList(); + final atDumpsiteTrips = trips.where((t) => t.status == 'at_dumpsite').toList(); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + if (inProgressTrips.isNotEmpty || atDumpsiteTrips.isNotEmpty) ...[ + const _SectionHeader(title: 'ACTIVE TRIP'), + const SizedBox(height: 8), + ...inProgressTrips.map((t) => _TripCard(trip: t, isActive: true)), + ...atDumpsiteTrips.map((t) => _TripCard(trip: t, isActive: true)), + const SizedBox(height: 20), + ], + if (scheduledTrips.isNotEmpty) ...[ + const _SectionHeader(title: 'SCHEDULED TRIPS'), + const SizedBox(height: 8), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: scheduledTrips.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, i) => _TripCard(trip: scheduledTrips[i]), + ), + ], + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorState( + message: err.toString(), + onRetry: () => ref.refresh(driverTripsProvider), + ), + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + title, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF737373), + letterSpacing: 0.6, + ), + ), + ); + } +} + +class _TripCard extends StatelessWidget { + const _TripCard({required this.trip, this.isActive = false}); + final Trip trip; + final bool isActive; + + Color _getStatusColor() { + return switch (trip.status) { + 'in_progress' => VerdeColors.verde700, + 'at_dumpsite' => const Color(0xFF3B82F6), // Blue + 'completed' => const Color(0xFF10B981), // Emerald + 'cancelled' => const Color(0xFFEF4444), // Red + _ => const Color(0xFF6B7280), // Gray + }; + } + + @override + Widget build(BuildContext context) { + final statusColor = _getStatusColor(); + + return Card( + margin: EdgeInsets.zero, + elevation: isActive ? 3 : 0, + shadowColor: isActive ? VerdeColors.verde500.withValues(alpha: 0.2) : null, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: isActive ? VerdeColors.verde500 : const Color(0xFFE5E5E5), + width: isActive ? 1.5 : 1.0, + ), + ), + color: Colors.white, + child: InkWell( + onTap: () => context.push('/driver/trips/${trip.id}'), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + trip.tripNumber, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + fontFamily: 'monospace', + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + trip.status.replaceAll('_', ' ').toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: statusColor, + letterSpacing: 0.4, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon(Icons.route_rounded, size: 18, color: Color(0xFF737373)), + const SizedBox(width: 8), + Expanded( + child: Text( + trip.route?.name ?? 'Unknown Route', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.local_shipping_rounded, size: 18, color: Color(0xFF737373)), + const SizedBox(width: 8), + Text( + trip.truck?.plateNumber ?? 'No Truck', + style: const TextStyle(fontSize: 13, color: Color(0xFF525252)), + ), + const SizedBox(width: 16), + const Icon(Icons.calendar_month_rounded, size: 18, color: Color(0xFF737373)), + const SizedBox(width: 8), + Text( + trip.scheduledDate, + style: const TextStyle(fontSize: 13, color: Color(0xFF525252)), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: Color(0xFFF5F5F5)), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${trip.stops.length} stops scheduled', + style: const TextStyle(fontSize: 12, color: Color(0xFF737373)), + ), + const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Color(0xFFA3A3A3)), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _EmptyTrips extends StatelessWidget { + const _EmptyTrips(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.assignment_rounded, color: Color(0xFFA3A3A3), size: 48), + SizedBox(height: 12), + Text( + 'No trips assigned yet', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF525252)), + ), + SizedBox(height: 4), + Text( + 'Assigned trips will show up here.', + style: TextStyle(fontSize: 13, color: Color(0xFF737373)), + ), + ], + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline_rounded, color: Color(0xFFEF4444), size: 48), + const SizedBox(height: 16), + const Text( + 'Failed to load trips', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Color(0xFF737373)), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: onRetry, + child: const Text('Try Again'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/ui/driver/trip_detail_screen.dart b/lib/src/ui/driver/trip_detail_screen.dart new file mode 100644 index 0000000..eecbaf2 --- /dev/null +++ b/lib/src/ui/driver/trip_detail_screen.dart @@ -0,0 +1,803 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../data/driver/driver_location_service.dart'; +import '../../data/driver/driver_models.dart'; +import '../../data/driver/driver_providers.dart'; +import '../../data/driver/driver_repository.dart'; +import '../../theme/app_theme.dart'; + +class TripDetailScreen extends ConsumerStatefulWidget { + const TripDetailScreen({super.key, required this.uuid}); + final String uuid; + + @override + ConsumerState createState() => _TripDetailScreenState(); +} + +class _TripDetailScreenState extends ConsumerState { + bool _submitting = false; + + Future _runAction(Future Function() action) async { + setState(() => _submitting = true); + try { + await action(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceAll('ApiException(422): ', '')), + backgroundColor: const Color(0xFFB91C1C), + ), + ); + } + } finally { + if (mounted) { + setState(() => _submitting = false); + } + } + } + + Future _startTrip(Trip trip) async { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + await ref.read(activeTripProvider.notifier).startTrip( + trip.id, + pos?.latitude, + pos?.longitude, + ); + ref.invalidate(driverTripsProvider); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + }); + } + + Future _arriveAtStop(TripStop stop) async { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + if (pos == null) throw Exception('Unable to acquire GPS coordinates.'); + await ref.read(driverRepositoryProvider).arriveAtStop( + widget.uuid, + stop.id, + lat: pos.latitude, + lng: pos.longitude, + ); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + }); + } + + Future _departStop(TripStop stop) async { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + if (pos == null) throw Exception('Unable to acquire GPS coordinates.'); + await ref.read(driverRepositoryProvider).departStop( + widget.uuid, + stop.id, + lat: pos.latitude, + lng: pos.longitude, + ); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + }); + } + + Future _skipStop(TripStop stop) async { + final reasonController = TextEditingController(); + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Skip Stop', style: TextStyle(fontWeight: FontWeight.bold)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Why are you skipping ${stop.dropOffPointName}?'), + const SizedBox(height: 12), + TextField( + controller: reasonController, + decoration: const InputDecoration( + hintText: 'Enter reason (e.g. Road blocked, Closed)', + fillColor: Color(0xFFF5F5F5), + ), + minLines: 2, + maxLines: 4, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFEF4444), + minimumSize: const Size(80, 40), + ), + onPressed: () { + if (reasonController.text.trim().length >= 3) { + Navigator.pop(context, true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Reason must be at least 3 characters')), + ); + } + }, + child: const Text('Skip'), + ), + ], + ), + ); + + if (confirm == true) { + await _runAction(() async { + await ref.read(driverRepositoryProvider).skipStop( + widget.uuid, + stop.id, + reason: reasonController.text.trim(), + ); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + }); + } + } + + Future _arriveDumpsite(Trip trip) async { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + if (pos == null) throw Exception('Unable to acquire GPS coordinates.'); + try { + await ref.read(activeTripProvider.notifier).arriveDumpsite( + trip.id, + pos.latitude, + pos.longitude, + ); + ref.invalidate(driverTripsProvider); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + } catch (e) { + if (e.toString().contains('geofence') && mounted) { + final override = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Outside Geofence', style: TextStyle(fontWeight: FontWeight.bold)), + content: Text( + 'Your GPS coordinates are reading as outside of the ${trip.dumpsite?.name ?? "dumpsite"} boundaries.\n\n' + 'Would you like to override the geofence and record arrival anyway?', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Override & Arrive'), + ), + ], + ), + ); + + if (override == true) { + await ref.read(activeTripProvider.notifier).arriveDumpsite( + trip.id, + pos.latitude, + pos.longitude, + overrideGeofence: true, + ); + ref.invalidate(driverTripsProvider); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + } + } else { + rethrow; + } + } + }); + } + + Future _releaseLoad(Trip trip) async { + final weightController = TextEditingController(); + final attendantController = TextEditingController(); + final passController = TextEditingController(); + final notesController = TextEditingController(); + + final success = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Release Load', style: TextStyle(fontWeight: FontWeight.bold)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: weightController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Weight (kg)*', + hintText: 'e.g. 1200', + fillColor: Color(0xFFF5F5F5), + ), + ), + const SizedBox(height: 12), + TextField( + controller: passController, + decoration: const InputDecoration( + labelText: 'Gate Pass Number', + hintText: 'e.g. GP-9821', + fillColor: Color(0xFFF5F5F5), + ), + ), + const SizedBox(height: 12), + TextField( + controller: attendantController, + decoration: const InputDecoration( + labelText: 'Attendant Name', + hintText: 'e.g. John Doe', + fillColor: Color(0xFFF5F5F5), + ), + ), + const SizedBox(height: 12), + TextField( + controller: notesController, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: 'Any release remarks', + fillColor: Color(0xFFF5F5F5), + ), + maxLines: 2, + ), + ], + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + ElevatedButton( + onPressed: () { + final weight = int.tryParse(weightController.text.trim()); + if (weight == null || weight <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a valid weight in kg')), + ); + } else { + Navigator.pop(context, true); + } + }, + child: const Text('Log Release'), + ), + ], + ), + ); + + if (success == true) { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + await ref.read(driverRepositoryProvider).releaseLoad( + trip.id, + weightKg: int.parse(weightController.text.trim()), + gatePassNumber: passController.text.trim().isEmpty ? null : passController.text.trim(), + dumpsiteAttendantName: attendantController.text.trim().isEmpty ? null : attendantController.text.trim(), + lat: pos?.latitude, + lng: pos?.longitude, + notes: notesController.text.trim().isEmpty ? null : notesController.text.trim(), + ); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + }); + } + } + + Future _completeTrip(Trip trip) async { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + await ref.read(activeTripProvider.notifier).completeTrip( + trip.id, + pos?.latitude, + pos?.longitude, + ); + ref.invalidate(driverTripsProvider); + if (mounted) { + context.go('/driver/home'); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Trip completed successfully! Good job.'), + backgroundColor: VerdeColors.verde700, + ), + ); + } + }); + } + + Future _reportIncident() async { + final notesController = TextEditingController(); + String kind = 'incident'; + + final logged = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Report Incident', style: TextStyle(fontWeight: FontWeight.bold)), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + initialValue: kind, + decoration: const InputDecoration( + labelText: 'Type of Incident', + fillColor: Color(0xFFF5F5F5), + ), + items: const [ + DropdownMenuItem(value: 'incident', child: Text('General Incident')), + DropdownMenuItem(value: 'breakdown', child: Text('Truck Breakdown')), + ], + onChanged: (v) { + if (v != null) { + setDialogState(() => kind = v); + } + }, + ), + const SizedBox(height: 12), + TextField( + controller: notesController, + minLines: 3, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Description / Notes*', + hintText: 'Describe what happened...', + fillColor: Color(0xFFF5F5F5), + ), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFEF4444)), + onPressed: () { + if (notesController.text.trim().length >= 3) { + Navigator.pop(context, true); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Notes must be at least 3 characters')), + ); + } + }, + child: const Text('Report'), + ), + ], + ), + ), + ); + + if (logged == true) { + await _runAction(() async { + final pos = await ref.read(locationServiceProvider).getCurrentPosition(); + await ref.read(driverRepositoryProvider).reportIncident( + widget.uuid, + kind: kind, + notes: notesController.text.trim(), + lat: pos?.latitude, + lng: pos?.longitude, + ); + ref.invalidate(driverTripDetailsProvider(widget.uuid)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Incident reported and logged.')), + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final tripDetail = ref.watch(driverTripDetailsProvider(widget.uuid)); + + return Scaffold( + appBar: AppBar( + title: const Text('Trip Details', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18)), + actions: [ + tripDetail.when( + data: (trip) => trip.status == 'in_progress' || trip.status == 'at_dumpsite' + ? IconButton( + tooltip: 'Report incident', + icon: const Icon(Icons.warning_amber_rounded, color: Color(0xFFEF4444)), + onPressed: _submitting ? null : _reportIncident, + ) + : const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ), + ], + ), + body: SafeArea( + child: tripDetail.when( + data: (trip) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _TripSummaryHeader(trip: trip), + const SizedBox(height: 20), + const Text( + 'ROUTE TIMELINE', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF737373), + letterSpacing: 0.6, + ), + ), + const SizedBox(height: 12), + if (trip.stops.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Text( + 'No stops scheduled for this trip.', + style: TextStyle(color: Color(0xFF737373)), + ), + ), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: trip.stops.length, + itemBuilder: (context, i) { + final stop = trip.stops[i]; + final isFirst = i == 0; + final isLast = i == trip.stops.length - 1; + final isTripInProgress = trip.status == 'in_progress'; + // A stop can be operated on if the trip is in_progress AND all preceding stops are resolved (completed/skipped) + final precedingResolved = trip.stops.take(i).every((s) => s.status == 'completed' || s.status == 'skipped'); + final canOperate = isTripInProgress && precedingResolved; + + return _StopTimelineTile( + stop: stop, + isFirst: isFirst, + isLast: isLast, + canOperate: canOperate, + onArrive: () => _arriveAtStop(stop), + onDepart: () => _departStop(stop), + onSkip: () => _skipStop(stop), + submitting: _submitting, + ); + }, + ), + ], + ), + ), + _StickyActionBar( + trip: trip, + submitting: _submitting, + onStart: () => _startTrip(trip), + onArriveDumpsite: () => _arriveDumpsite(trip), + onRelease: () => _releaseLoad(trip), + onComplete: () => _completeTrip(trip), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline_rounded, color: Color(0xFFEF4444), size: 48), + const SizedBox(height: 12), + const Text('Error loading details', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text(err.toString(), textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.invalidate(driverTripDetailsProvider(widget.uuid)), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TripSummaryHeader extends StatelessWidget { + const _TripSummaryHeader({required this.trip}); + final Trip trip; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E5E5)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + trip.tripNumber, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, fontFamily: 'monospace'), + ), + Text( + trip.status.replaceAll('_', ' ').toUpperCase(), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF737373)), + ), + ], + ), + const SizedBox(height: 14), + _SummaryRow( + label: 'Route', + value: trip.route != null + ? '${trip.route!.name} (${trip.route!.code}) · ${trip.route!.totalDistanceKm} km · ${trip.route!.estimatedDurationMinutes} mins' + : 'No route name', + ), + _SummaryRow(label: 'Truck', value: '${trip.truck?.plateNumber ?? "N/A"} (${trip.truck?.model ?? "N/A"})'), + _SummaryRow(label: 'Dumpsite', value: trip.dumpsite?.name ?? 'No dumpsite assigned'), + if (trip.team != null) ...[ + _SummaryRow(label: 'Team', value: trip.team!.name), + _SummaryRow( + label: 'Crew', + value: 'Driver: ${trip.team!.driverName ?? "N/A"} · Scanner: ${trip.team!.scannerName ?? "N/A"}', + ), + ], + if (trip.actualStartTime != null) + _SummaryRow( + label: 'Started At', + value: '${trip.actualStartTime!.hour.toString().padLeft(2, '0')}:${trip.actualStartTime!.minute.toString().padLeft(2, '0')}', + ), + if (trip.totalLoadKg > 0) _SummaryRow(label: 'Logged Load', value: '${trip.totalLoadKg} kg'), + ], + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + const _SummaryRow({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF737373), fontWeight: FontWeight.w500)), + ), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 13, color: Color(0xFF171717), fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + +class _StopTimelineTile extends StatelessWidget { + const _StopTimelineTile({ + required this.stop, + required this.isFirst, + required this.isLast, + required this.canOperate, + required this.onArrive, + required this.onDepart, + required this.onSkip, + required this.submitting, + }); + + final TripStop stop; + final bool isFirst; + final bool isLast; + final bool canOperate; + final VoidCallback onArrive; + final VoidCallback onDepart; + final VoidCallback onSkip; + final bool submitting; + + Color _getIndicatorColor() { + return switch (stop.status) { + 'completed' => VerdeColors.verde600, + 'skipped' => const Color(0xFFEF4444), + 'arrived' => const Color(0xFFF59E0B), + _ => const Color(0xFFD1D5DB), + }; + } + + @override + Widget build(BuildContext context) { + final color = _getIndicatorColor(); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Visual timeline line and indicator dot + Column( + children: [ + Container( + width: 2, + height: 12, + color: isFirst ? Colors.transparent : const Color(0xFFE5E5E5), + ), + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + Container( + width: 2, + height: 60, + color: isLast ? Colors.transparent : const Color(0xFFE5E5E5), + ), + ], + ), + const SizedBox(width: 12), + // Stop details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 6), + Text( + stop.dropOffPointName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + decoration: stop.status == 'skipped' ? TextDecoration.lineThrough : null, + color: stop.status == 'skipped' ? const Color(0xFFA3A3A3) : null, + ), + ), + if (stop.status == 'skipped') + Text( + 'Skipped: "${stop.skipReason ?? "No reason"}"', + style: const TextStyle(fontSize: 11, color: Color(0xFFEF4444), fontWeight: FontWeight.w600), + ) + else if (stop.status == 'completed') + Text( + 'Completed · Logged ${stop.estimatedLoadAddedKg} kg (${stop.totalScans} scans)', + style: const TextStyle(fontSize: 11, color: VerdeColors.verde700, fontWeight: FontWeight.w600), + ) + else if (stop.status == 'arrived') + const Text( + 'Arrived · Waiting to depart', + style: TextStyle(fontSize: 11, color: Color(0xFFD97706), fontWeight: FontWeight.w600), + ) + else + const Text( + 'Scheduled', + style: TextStyle(fontSize: 11, color: Color(0xFF737373)), + ), + const SizedBox(height: 8), + // Action buttons inside the timeline node + if (canOperate) ...[ + if (stop.status == 'pending') + Row( + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + minimumSize: const Size(100, 36), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + onPressed: submitting ? null : onArrive, + child: const Text('Arrive', style: TextStyle(fontSize: 13)), + ), + const SizedBox(width: 10), + OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFEF4444), + side: const BorderSide(color: Color(0xFFFCA5A5)), + minimumSize: const Size(80, 36), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + onPressed: submitting ? null : onSkip, + child: const Text('Skip', style: TextStyle(fontSize: 13)), + ), + ], + ) + else if (stop.status == 'arrived') + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD97706), // Amber + minimumSize: const Size(120, 36), + padding: const EdgeInsets.symmetric(horizontal: 14), + ), + onPressed: submitting ? null : onDepart, + child: const Text('Depart Stop', style: TextStyle(fontSize: 13)), + ), + const SizedBox(height: 12), + ], + ], + ), + ), + ], + ); + } +} + +class _StickyActionBar extends StatelessWidget { + const _StickyActionBar({ + required this.trip, + required this.submitting, + required this.onStart, + required this.onArriveDumpsite, + required this.onRelease, + required this.onComplete, + }); + + final Trip trip; + final bool submitting; + final VoidCallback onStart; + final VoidCallback onArriveDumpsite; + final VoidCallback onRelease; + final VoidCallback onComplete; + + @override + Widget build(BuildContext context) { + Widget buttonContent; + + if (trip.status == 'scheduled') { + buttonContent = ElevatedButton( + onPressed: submitting ? null : onStart, + child: const Text('START TRIP'), + ); + } else if (trip.status == 'in_progress') { + final allStopsResolved = trip.stops.every((s) => s.status == 'completed' || s.status == 'skipped'); + buttonContent = ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: allStopsResolved ? const Color(0xFF2563EB) : VerdeColors.verde600, + ), + onPressed: submitting ? null : onArriveDumpsite, + child: Text(allStopsResolved ? 'ARRIVE AT DUMPSITE' : 'HEAD TO DUMPSITE NOW'), + ); + } else if (trip.status == 'at_dumpsite') { + buttonContent = Row( + children: [ + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: VerdeColors.verde600), + onPressed: submitting ? null : onRelease, + child: const Text('RELEASE LOAD'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF171717), + side: const BorderSide(color: Color(0xFFD4D4D4)), + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: submitting ? null : onComplete, + child: const Text('COMPLETE TRIP', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ), + ], + ); + } else { + // Completed or Cancelled + buttonContent = const SizedBox.shrink(); + } + + if (buttonContent is SizedBox) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFE5E5E5))), + ), + child: buttonContent, + ); + } +} diff --git a/lib/src/ui/helper/helper_home_screen.dart b/lib/src/ui/helper/helper_home_screen.dart new file mode 100644 index 0000000..bcf319f --- /dev/null +++ b/lib/src/ui/helper/helper_home_screen.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../data/auth/auth_controller.dart'; +import '../../data/helper/helper_models.dart'; +import '../../data/helper/helper_providers.dart'; +import '../../theme/app_theme.dart'; + +class HelperHomeScreen extends ConsumerWidget { + const HelperHomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authControllerProvider); + if (auth is! Authenticated) return const SizedBox.shrink(); + + final assignmentState = ref.watch(currentAssignmentProvider); + final historyState = ref.watch(helperHistoryProvider); + + return Scaffold( + appBar: AppBar( + titleSpacing: 16, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Hi, ${auth.user.fullName.split(' ').first}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + Text('${auth.tenantName} · Helper', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF737373))), + ], + ), + actions: [ + IconButton( + tooltip: 'Sign out', + icon: const Icon(Icons.logout_rounded), + onPressed: () => ref.read(authControllerProvider.notifier).logout(), + ), + ], + ), + body: SafeArea( + child: RefreshIndicator( + onRefresh: () async { + ref.read(currentAssignmentProvider.notifier).refresh(); + ref.invalidate(helperHistoryProvider); + }, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + const _SectionHeader(title: "TODAY'S TEAM ASSIGNMENT"), + const SizedBox(height: 8), + assignmentState.when( + data: (assignment) { + if (assignment == null) { + return const _NoAssignmentCard(); + } + return _TodayAssignmentCard(assignment: assignment); + }, + loading: () => const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: CircularProgressIndicator(), + ), + ), + error: (err, _) => _ErrorCard( + message: err.toString(), + onRetry: () => ref.read(currentAssignmentProvider.notifier).refresh(), + ), + ), + const SizedBox(height: 24), + const _SectionHeader(title: 'ASSIGNMENT HISTORY'), + const SizedBox(height: 8), + historyState.when( + data: (history) { + if (history.isEmpty) { + return const Card( + elevation: 0, + color: Color(0xFFF5F5F5), + child: Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Text( + 'No past assignments found.', + style: TextStyle(color: Color(0xFF737373), fontSize: 14), + ), + ), + ), + ); + } + return ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: history.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = history[index]; + return _HistoryRow(assignment: item); + }, + ); + }, + loading: () => const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: CircularProgressIndicator(), + ), + ), + error: (err, _) => Center( + child: Text( + 'Failed to load history: $err', + style: const TextStyle(color: Colors.red, fontSize: 13), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + final String title; + + @override + Widget build(BuildContext context) { + return Text( + title, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF737373), letterSpacing: 0.8), + ); + } +} + +class _NoAssignmentCard extends StatelessWidget { + const _NoAssignmentCard(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: const Column( + children: [ + Icon(Icons.calendar_today_outlined, size: 36, color: Color(0xFFA3A3A3)), + SizedBox(height: 12), + Text( + 'No Active Assignment', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF171717)), + ), + SizedBox(height: 4), + Text( + "You aren't assigned to a team for today. Please wait for dispatch office updates.", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Color(0xFF737373)), + ), + ], + ), + ); + } +} + +class _ErrorCard extends StatelessWidget { + const _ErrorCard({required this.message, required this.onRetry}); + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFECACA)), + ), + child: Column( + children: [ + const Icon(Icons.error_outline_rounded, color: Color(0xFFDC2626), size: 28), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: Color(0xFFB91C1C)), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButtonStyleFrom( + backgroundColor: const Color(0xFFDC2626), + foregroundColor: Colors.white, + ), + child: const Text('Retry'), + ), + ], + ), + ); + } +} + +class _TodayAssignmentCard extends ConsumerWidget { + const _TodayAssignmentCard({required this.assignment}); + final HelperAssignment assignment; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final team = assignment.team; + final status = assignment.status; + + Color badgeColor; + String badgeText; + IconData badgeIcon; + + if (status == 'accepted') { + badgeColor = VerdeColors.verde600; + badgeText = 'ASSIGNMENT ACCEPTED'; + badgeIcon = Icons.check_circle_outline_rounded; + } else if (status == 'rejected') { + badgeColor = const Color(0xFFDC2626); + badgeText = 'ASSIGNMENT REJECTED'; + badgeIcon = Icons.cancel_outlined; + } else { + badgeColor = const Color(0xFFD97706); + badgeText = 'PENDING CONFIRMATION'; + badgeIcon = Icons.hourglass_empty_rounded; + } + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE5E5E5)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: badgeColor.withOpacity(0.08), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + border: Border(bottom: BorderSide(color: badgeColor.withOpacity(0.12))), + ), + child: Row( + children: [ + Icon(badgeIcon, size: 16, color: badgeColor), + const SizedBox(width: 6), + Text( + badgeText, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: badgeColor, letterSpacing: 0.4), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(team.name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF171717))), + const SizedBox(height: 2), + Text('Scheduled: ${assignment.assignedFrom}', + style: const TextStyle(fontSize: 12, color: Color(0xFF737373))), + const SizedBox(height: 16), + _TeamMemberRow(icon: Icons.local_shipping_outlined, role: 'Truck', name: team.truck?.plateNumber ?? 'No truck assigned'), + const SizedBox(height: 10), + _TeamMemberRow(icon: Icons.person_outline_rounded, role: 'Driver', name: team.driverName ?? 'No driver assigned'), + const SizedBox(height: 10), + _TeamMemberRow(icon: Icons.qr_code_scanner_rounded, role: 'Scanner', name: team.scannerName ?? 'No scanner assigned'), + if (status == 'pending') ...[ + const SizedBox(height: 20), + const Divider(color: Color(0xFFF5F5F5), height: 1), + const SizedBox(height: 16), + const Text( + 'Confirm your assignment to check-in for this shift.', + style: TextStyle(fontSize: 13, color: Color(0xFF737373), fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => ref.read(currentAssignmentProvider.notifier).reject(), + style: OutlinedButton.styleFrom( + side: const BorderSide(color: Color(0xFFDC2626)), + foregroundColor: const Color(0xFFDC2626), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Reject', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () => ref.read(currentAssignmentProvider.notifier).accept(), + style: ElevatedButton.styleFrom( + backgroundColor: VerdeColors.verde600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Accept', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ] else if (status == 'rejected') ...[ + const SizedBox(height: 20), + const Divider(color: Color(0xFFF5F5F5), height: 1), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.read(currentAssignmentProvider.notifier).accept(), + style: ElevatedButton.styleFrom( + backgroundColor: VerdeColors.verde600, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('Change to Accept', style: TextStyle(fontWeight: FontWeight.w600)), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _TeamMemberRow extends StatelessWidget { + const _TeamMemberRow({required this.icon, required this.role, required this.name}); + final IconData icon; + final String role; + final String name; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 20, color: const Color(0xFF737373)), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + role.toUpperCase(), + style: const TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: Color(0xFF737373), letterSpacing: 0.4), + ), + Text( + name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF171717)), + ), + ], + ), + ], + ); + } +} + +class _HistoryRow extends StatelessWidget { + const _HistoryRow({required this.assignment}); + final HelperAssignment assignment; + + @override + Widget build(BuildContext context) { + final status = assignment.status; + Color badgeColor; + String badgeText; + + if (status == 'accepted') { + badgeColor = VerdeColors.verde600; + badgeText = 'Accepted'; + } else if (status == 'rejected') { + badgeColor = const Color(0xFFDC2626); + badgeText = 'Rejected'; + } else { + badgeColor = const Color(0xFFD97706); + badgeText = 'Pending'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE5E5E5)), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + assignment.team.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF171717)), + ), + const SizedBox(height: 2), + Text( + assignment.assignedFrom, + style: const TextStyle(fontSize: 11, color: Color(0xFF737373)), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: badgeColor.withOpacity(0.08), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: badgeColor.withOpacity(0.12)), + ), + child: Text( + badgeText, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: badgeColor), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/ui/home/home_screen.dart b/lib/src/ui/home/home_screen.dart index 3892abd..24445f2 100644 --- a/lib/src/ui/home/home_screen.dart +++ b/lib/src/ui/home/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../data/auth/auth_controller.dart'; +import '../../data/dop/dop_repository.dart'; import '../../data/scan/scan_models.dart'; import '../../data/scan/scan_queue.dart'; import '../../data/scan/scan_repository.dart'; @@ -13,7 +14,6 @@ class HomeScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - // Seed the pending count from the on-disk queue once. ref.watch(pendingCountInitProvider); final auth = ref.watch(authControllerProvider); @@ -23,60 +23,51 @@ class HomeScreen extends ConsumerWidget { final acceptedToday = recent.where((r) => r.accepted).length; final pending = ref.watch(pendingCountProvider); - return Scaffold( - appBar: AppBar( - titleSpacing: 16, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Hi, ${auth.user.fullName.split(' ').first}', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), - Text(auth.tenantName, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF737373))), - ], - ), - actions: [ - IconButton( - tooltip: 'Sign out', - icon: const Icon(Icons.logout_rounded), - onPressed: () => ref.read(authControllerProvider.notifier).logout(), - ), - ], - ), - body: SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + titleSpacing: 16, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _DopBanner(name: auth.activeDopName ?? 'Tap to choose', onChange: () { - ref.read(authControllerProvider.notifier).clearActiveDop(); - context.go('/dop'); - }), - const SizedBox(height: 16), - _ScanCta(enabled: auth.hasActiveDop), - if (pending > 0) ...[ - const SizedBox(height: 12), - _PendingBanner(count: pending), - ], - const SizedBox(height: 16), - _CounterRow(accepted: acceptedToday, total: recent.length), - const SizedBox(height: 24), - const Padding( - padding: EdgeInsets.only(left: 4, bottom: 8), - child: Text('RECENT SCANS', - style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, - color: Color(0xFF737373), letterSpacing: 0.6)), - ), - Expanded( - child: recent.isEmpty - ? const _EmptyRecent() - : ListView.separated( - itemCount: recent.length, - separatorBuilder: (_, __) => const SizedBox(height: 6), - itemBuilder: (_, i) => _RecentTile(r: recent[i]), - ), + Text('Hi, ${auth.user.fullName.split(' ').first}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + Text('${auth.tenantName} · Scanner', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF737373))), + ], + ), + actions: [ + IconButton( + tooltip: 'Sign out', + icon: const Icon(Icons.logout_rounded), + onPressed: () => ref.read(authControllerProvider.notifier).logout(), + ), + ], + bottom: const TabBar( + tabs: [ + Tab(text: 'Scan', icon: Icon(Icons.qr_code_scanner_rounded, size: 20)), + Tab(text: 'Crew & Truck', icon: Icon(Icons.local_shipping_rounded, size: 20)), + Tab(text: 'DOP Map', icon: Icon(Icons.map_rounded, size: 20)), + ], + labelColor: VerdeColors.verde700, + unselectedLabelColor: Color(0xFF737373), + indicatorColor: VerdeColors.verde700, + indicatorSize: TabBarIndicatorSize.tab, + labelStyle: TextStyle(fontWeight: FontWeight.w700, fontSize: 13), + ), + ), + body: SafeArea( + child: TabBarView( + children: [ + _ScanTab( + auth: auth, + recent: recent, + acceptedToday: acceptedToday, + pending: pending, ), + const _CrewTab(), + const _MapTab(), ], ), ), @@ -85,6 +76,438 @@ class HomeScreen extends ConsumerWidget { } } +class _ScanTab extends ConsumerWidget { + const _ScanTab({ + required this.auth, + required this.recent, + required this.acceptedToday, + required this.pending, + }); + + final Authenticated auth; + final List recent; + final int acceptedToday; + final int pending; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _DopBanner( + name: auth.activeDopName ?? 'Tap to choose', + onChange: () { + ref.read(authControllerProvider.notifier).clearActiveDop(); + context.go('/dop'); + }, + ), + const SizedBox(height: 16), + _ScanCta(enabled: auth.hasActiveDop), + if (pending > 0) ...[ + const SizedBox(height: 12), + _PendingBanner(count: pending), + ], + const SizedBox(height: 16), + _CounterRow(accepted: acceptedToday, total: recent.length), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(left: 4, bottom: 8), + child: Text( + 'RECENT SCANS', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF737373), + letterSpacing: 0.6, + ), + ), + ), + Expanded( + child: recent.isEmpty + ? const _EmptyRecent() + : ListView.separated( + itemCount: recent.length, + separatorBuilder: (_, _) => const SizedBox(height: 6), + itemBuilder: (_, i) => _RecentTile(r: recent[i]), + ), + ), + ], + ), + ); + } +} + +class _CrewTab extends ConsumerWidget { + const _CrewTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detailsAsync = ref.watch(scannerTeamDetailsProvider); + + return RefreshIndicator( + onRefresh: () => ref.refresh(scannerTeamDetailsProvider.future), + child: detailsAsync.when( + data: (details) { + final team = details.team; + final truck = team.truck; + final trip = details.activeTrip; + + return ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + _InfoCard( + title: 'COLLECTION CREW', + icon: Icons.people_rounded, + iconColor: VerdeColors.verde700, + children: [ + _InfoRow(label: 'Team Name', value: team.name), + _InfoRow(label: 'Driver', value: team.driverName ?? 'No driver assigned'), + _InfoRow(label: 'Scanner', value: team.scannerName ?? 'No scanner assigned'), + _InfoRow(label: 'Status', value: team.status.toUpperCase()), + ], + ), + const SizedBox(height: 16), + if (truck != null) ...[ + _InfoCard( + title: 'TRUCK DETAILS', + icon: Icons.local_shipping_rounded, + iconColor: Colors.blue, + children: [ + _InfoRow(label: 'Plate Number', value: truck.plateNumber), + _InfoRow(label: 'Model', value: truck.model ?? 'N/A'), + _InfoRow(label: 'Capacity', value: '${truck.capacityKg} kg'), + _InfoRow(label: 'Status', value: truck.status.toUpperCase()), + ], + ), + const SizedBox(height: 16), + ], + if (trip != null) ...[ + _InfoCard( + title: 'ACTIVE TRIP', + icon: Icons.route_rounded, + iconColor: Colors.orange, + children: [ + _InfoRow(label: 'Trip Number', value: trip.tripNumber), + _InfoRow(label: 'Route', value: trip.route?.name ?? 'N/A'), + _InfoRow(label: 'Scheduled Date', value: trip.scheduledDate), + _InfoRow(label: 'Trip Status', value: trip.status.replaceAll('_', ' ').toUpperCase()), + ], + ), + ], + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) { + return Center( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.assignment_ind_rounded, color: Color(0xFF9CA3AF), size: 48), + const SizedBox(height: 16), + const Text( + 'No Active Team Assignment', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + Text( + err.toString().contains('404') + ? 'You are not currently linked to an active LGU collection crew.' + : err.toString(), + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF737373)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class _MapTab extends ConsumerWidget { + const _MapTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detailsAsync = ref.watch(scannerTeamDetailsProvider); + final nearbyDopsAsync = ref.watch(nearbyDopsProvider); + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(scannerTeamDetailsProvider); + ref.invalidate(nearbyDopsProvider); + }, + child: detailsAsync.when( + data: (details) { + final trip = details.activeTrip; + final stops = trip?.stops ?? []; + + if (stops.isEmpty) { + return _buildNearbyDopsMap(ref, nearbyDopsAsync); + } + + final points = stops + .where((s) => s.dropOffPointCoordinates?.lat != null && s.dropOffPointCoordinates?.lng != null) + .map((s) => Offset(s.dropOffPointCoordinates!.lng, s.dropOffPointCoordinates!.lat)) + .toList(); + + return ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const Text( + 'ROUTE MAP', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF737373), letterSpacing: 0.6), + ), + const SizedBox(height: 8), + if (points.isNotEmpty) + Container( + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF9FAFB), + border: Border.all(color: const Color(0xFFE5E5E5)), + borderRadius: BorderRadius.circular(12), + ), + child: CustomPaint( + painter: _RouteMapPainter( + points: points, + activePointIndex: stops.indexWhere((s) => s.status == 'arrived' || s.status == 'pending'), + ), + ), + ), + const SizedBox(height: 20), + const Text( + 'DROP-OFF POINTS SEQUENCE', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF737373), letterSpacing: 0.6), + ), + const SizedBox(height: 8), + ...stops.map((s) => Card( + margin: const EdgeInsets.only(bottom: 8), + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE5E5E5)), + ), + elevation: 0, + child: ListTile( + dense: true, + leading: CircleAvatar( + radius: 12, + backgroundColor: s.status == 'completed' + ? VerdeColors.verde100 + : const Color(0xFFF3F4F6), + child: Text( + '${s.sequence}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: s.status == 'completed' + ? VerdeColors.verde700 + : const Color(0xFF4B5563), + ), + ), + ), + title: Text(s.dropOffPointName, style: const TextStyle(fontWeight: FontWeight.w700)), + subtitle: s.dropOffPointCoordinates?.lat != null && s.dropOffPointCoordinates?.lng != null + ? Text('GPS: ${s.dropOffPointCoordinates!.lat.toStringAsFixed(4)}, ${s.dropOffPointCoordinates!.lng.toStringAsFixed(4)}') + : null, + trailing: Text( + s.status.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: s.status == 'completed' + ? VerdeColors.verde700 + : const Color(0xFF737373), + ), + ), + ), + )), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => _buildNearbyDopsMap(ref, nearbyDopsAsync), + ), + ); + } + + Widget _buildNearbyDopsMap(WidgetRef ref, AsyncValue> nearbyDopsAsync) { + return nearbyDopsAsync.when( + data: (dops) { + final validDops = dops.where((d) => d.lat != null && d.lng != null).toList(); + final points = validDops.map((d) => Offset(d.lng!, d.lat!)).toList(); + + return ListView( + padding: const EdgeInsets.all(16), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const Text( + 'NEARBY DROP-OFF POINTS MAP', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF737373), letterSpacing: 0.6), + ), + const SizedBox(height: 8), + if (points.isNotEmpty) + Container( + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF9FAFB), + border: Border.all(color: const Color(0xFFE5E5E5)), + borderRadius: BorderRadius.circular(12), + ), + child: CustomPaint( + painter: _RouteMapPainter( + points: points, + activePointIndex: -1, + ), + ), + ) + else + Container( + height: 120, + alignment: Alignment.center, + child: const Text('No GPS locations mapped in radius.', style: TextStyle(color: Color(0xFF737373))), + ), + const SizedBox(height: 20), + const Text( + 'DROP-OFF POINTS LIST', + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Color(0xFF737373), letterSpacing: 0.6), + ), + const SizedBox(height: 8), + ...dops.map((d) => Card( + margin: const EdgeInsets.only(bottom: 8), + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Color(0xFFE5E5E5)), + ), + elevation: 0, + child: ListTile( + dense: true, + title: Text(d.name, style: const TextStyle(fontWeight: FontWeight.w700)), + subtitle: Text( + 'Code: ${d.code}' + '${d.distanceMeters != null ? " · ${(d.distanceMeters! / 1000).toStringAsFixed(2)} km away" : ""}', + ), + trailing: d.lat != null && d.lng != null + ? const Icon(Icons.location_on_rounded, color: VerdeColors.verde600, size: 20) + : null, + ), + )), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.map_rounded, color: Color(0xFF9CA3AF), size: 48), + const SizedBox(height: 12), + const Text('Could not load drop-off map', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text(err.toString(), textAlign: TextAlign.center), + ], + ), + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({ + required this.title, + required this.icon, + required this.iconColor, + required this.children, + }); + + final String title; + final IconData icon; + final Color iconColor; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE5E5E5)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), + child: Row( + children: [ + Icon(icon, color: iconColor, size: 20), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF737373), + letterSpacing: 0.6, + ), + ), + ], + ), + ), + const Divider(height: 1, color: Color(0xFFE5E5E5)), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: children, + ), + ), + ], + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF737373), fontWeight: FontWeight.w500)), + ), + Expanded( + child: Text(value, style: const TextStyle(fontSize: 13, color: Color(0xFF171717), fontWeight: FontWeight.w600)), + ), + ], + ), + ); + } +} + class _DopBanner extends StatelessWidget { const _DopBanner({required this.name, required this.onChange}); final String name; @@ -125,6 +548,7 @@ class _DopBanner extends StatelessWidget { class _ScanCta extends StatelessWidget { const _ScanCta({required this.enabled}); final bool enabled; + @override Widget build(BuildContext context) { return InkWell( @@ -184,6 +608,7 @@ class _CounterRow extends StatelessWidget { const _CounterRow({required this.accepted, required this.total}); final int accepted; final int total; + @override Widget build(BuildContext context) { return Row( @@ -201,6 +626,7 @@ class _Counter extends StatelessWidget { final String label; final int value; final Color accent; + @override Widget build(BuildContext context) { return Container( @@ -227,6 +653,7 @@ class _Counter extends StatelessWidget { class _RecentTile extends StatelessWidget { const _RecentTile({required this.r}); final ScanResult r; + @override Widget build(BuildContext context) { final (icon, fg, bg) = switch (r.outcome) { @@ -273,6 +700,7 @@ class _RecentTile extends StatelessWidget { class _EmptyRecent extends StatelessWidget { const _EmptyRecent(); + @override Widget build(BuildContext context) { return Center( @@ -368,3 +796,74 @@ class _PendingBannerState extends ConsumerState<_PendingBanner> { ); } } + +class _RouteMapPainter extends CustomPainter { + _RouteMapPainter({required this.points, required this.activePointIndex}); + final List points; + final int activePointIndex; + + @override + void paint(Canvas canvas, Size size) { + if (points.isEmpty) return; + + double minX = points.first.dx; + double maxX = points.first.dx; + double minY = points.first.dy; + double maxY = points.first.dy; + + for (final p in points) { + if (p.dx < minX) minX = p.dx; + if (p.dx > maxX) maxX = p.dx; + if (p.dy < minY) minY = p.dy; + if (p.dy > maxY) maxY = p.dy; + } + + final double widthRange = maxX - minX; + final double heightRange = maxY - minY; + + const double padding = 30.0; + final double scaleX = widthRange == 0 ? 1.0 : (size.width - padding * 2) / widthRange; + final double scaleY = heightRange == 0 ? 1.0 : (size.height - padding * 2) / heightRange; + final double scale = scaleX < scaleY ? scaleX : scaleY; + + final List scaledPoints = points.map((p) { + final x = padding + (p.dx - minX) * scale + (size.width - padding * 2 - (widthRange * scale)) / 2; + final y = size.height - (padding + (p.dy - minY) * scale + (size.height - padding * 2 - (heightRange * scale)) / 2); + return Offset(x, y); + }).toList(); + + if (activePointIndex >= 0 && scaledPoints.length > 1) { + final linePaint = Paint() + ..color = const Color(0xFFD1D5DB) + ..strokeWidth = 2.0 + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + final path = Path(); + path.moveTo(scaledPoints.first.dx, scaledPoints.first.dy); + for (int i = 1; i < scaledPoints.length; i++) { + path.lineTo(scaledPoints[i].dx, scaledPoints[i].dy); + } + canvas.drawPath(path, linePaint); + } + + final activePaint = Paint()..color = VerdeColors.verde700; + final regularPaint = Paint()..color = const Color(0xFF9CA3AF); + final borderPaint = Paint() + ..color = Colors.white + ..strokeWidth = 2.0 + ..style = PaintingStyle.stroke; + + for (int i = 0; i < scaledPoints.length; i++) { + final p = scaledPoints[i]; + final isActive = i == activePointIndex; + canvas.drawCircle(p, isActive ? 8.0 : 6.0, isActive ? activePaint : regularPaint); + canvas.drawCircle(p, isActive ? 8.0 : 6.0, borderPaint); + } + } + + @override + bool shouldRepaint(covariant _RouteMapPainter oldDelegate) { + return oldDelegate.points != points || oldDelegate.activePointIndex != activePointIndex; + } +} diff --git a/pubspec.lock b/pubspec.lock index 792b5e3..f846bad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -264,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" + url: "https://pub.dev" + source: hosted + version: "8.1.0" hooks: dependency: transitive description: @@ -272,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -364,10 +380,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -665,10 +681,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 93f553b..21e0c23 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: # Local persistence for the offline scan buffer (Phase 2) shared_preferences: ^2.3.3 uuid: ^4.5.1 + google_fonts: ^8.1.0 dev_dependencies: flutter_test: