Implement helper dashboard, providers, assignment check-in, and routing in mobile app
This commit is contained in:
@@ -61,8 +61,8 @@ class AuthRepository {
|
||||
}
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
final user = ScannerUser.fromJson(data['user'] as Map<String, dynamic>);
|
||||
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;
|
||||
|
||||
@@ -12,6 +12,8 @@ class DopOption {
|
||||
required this.name,
|
||||
required this.code,
|
||||
this.distanceMeters,
|
||||
this.lat,
|
||||
this.lng,
|
||||
});
|
||||
|
||||
factory DopOption.fromJson(Map<String, dynamic> 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 {
|
||||
|
||||
108
lib/src/data/driver/driver_location_service.dart
Normal file
108
lib/src/data/driver/driver_location_service.dart
Normal file
@@ -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<Position>? _positionStreamSub;
|
||||
String? _activeTruckUuid;
|
||||
Timer? _telemetryTimer;
|
||||
Position? _lastPosition;
|
||||
|
||||
// Checks and requests location permissions
|
||||
Future<bool> 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<void> 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<void> stopTracking() async {
|
||||
await _positionStreamSub?.cancel();
|
||||
_positionStreamSub = null;
|
||||
_telemetryTimer?.cancel();
|
||||
_telemetryTimer = null;
|
||||
_activeTruckUuid = null;
|
||||
_lastPosition = null;
|
||||
}
|
||||
|
||||
// Get current position once (e.g., for actions)
|
||||
Future<Position?> 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<LocationService>((ref) {
|
||||
final service = LocationService(ref.watch(driverRepositoryProvider));
|
||||
ref.onDispose(() {
|
||||
service.stopTracking();
|
||||
});
|
||||
return service;
|
||||
});
|
||||
264
lib/src/data/driver/driver_models.dart
Normal file
264
lib/src/data/driver/driver_models.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
class LatLngLiteral {
|
||||
LatLngLiteral({required this.lat, required this.lng});
|
||||
factory LatLngLiteral.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>)
|
||||
: null,
|
||||
boundaryPolygon: j['boundary_polygon'] != null
|
||||
? (j['boundary_polygon'] as List)
|
||||
.map((p) => LatLngLiteral.fromJson(p as Map<String, dynamic>))
|
||||
.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<LatLngLiteral>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>) : 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<String, dynamic> j) {
|
||||
final dop = j['drop_off_point'] as Map<String, dynamic>? ?? {};
|
||||
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<String, dynamic>)
|
||||
: 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<String, dynamic> 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<String, dynamic>) : null,
|
||||
team: j['team'] != null ? CollectionTeam.fromJson(j['team'] as Map<String, dynamic>) : null,
|
||||
truck: j['truck'] != null ? Truck.fromJson(j['truck'] as Map<String, dynamic>) : null,
|
||||
dumpsite: j['dumpsite'] != null ? Dumpsite.fromJson(j['dumpsite'] as Map<String, dynamic>) : null,
|
||||
stops: j['stops'] != null
|
||||
? (j['stops'] as List).map((s) => TripStop.fromJson(s as Map<String, dynamic>)).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<TripStop> stops;
|
||||
}
|
||||
53
lib/src/data/driver/driver_providers.dart
Normal file
53
lib/src/data/driver/driver_providers.dart
Normal file
@@ -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<List<Trip>>((ref) async {
|
||||
return ref.watch(driverRepositoryProvider).fetchTrips();
|
||||
});
|
||||
|
||||
// Async provider for fetching single trip details
|
||||
final driverTripDetailsProvider = FutureProvider.autoDispose.family<Trip, String>((ref, uuid) async {
|
||||
return ref.watch(driverRepositoryProvider).fetchTrip(uuid);
|
||||
});
|
||||
|
||||
// StateNotifier to track the active trip and manage location tracking lifecycle
|
||||
class ActiveTripNotifier extends StateNotifier<Trip?> {
|
||||
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<void> startTrip(String uuid, double? lat, double? lng) async {
|
||||
final updatedTrip = await _repository.startTrip(uuid, lat: lat, lng: lng);
|
||||
setActiveTrip(updatedTrip);
|
||||
}
|
||||
|
||||
Future<void> completeTrip(String uuid, double? lat, double? lng) async {
|
||||
await _repository.completeTrip(uuid, lat: lat, lng: lng);
|
||||
setActiveTrip(null);
|
||||
}
|
||||
|
||||
Future<void> 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<ActiveTripNotifier, Trip?>((ref) {
|
||||
return ActiveTripNotifier(
|
||||
ref.watch(locationServiceProvider),
|
||||
ref.watch(driverRepositoryProvider),
|
||||
);
|
||||
});
|
||||
165
lib/src/data/driver/driver_repository.dart
Normal file
165
lib/src/data/driver/driver_repository.dart
Normal file
@@ -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<List<Trip>> fetchTrips() async {
|
||||
final res = await _dio.get('/driver/trips');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
final list = body['data'] as List;
|
||||
return list.map((t) => Trip.fromJson(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load trips');
|
||||
}
|
||||
|
||||
Future<Trip> fetchTrip(String uuid) async {
|
||||
final res = await _dio.get('/driver/trips/$uuid');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
return Trip.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load trip details');
|
||||
}
|
||||
|
||||
Future<Trip> 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<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
return Trip.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to start trip');
|
||||
}
|
||||
|
||||
Future<TripStop> 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<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
return TripStop.fromJson(data['stop'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to record arrival');
|
||||
}
|
||||
|
||||
Future<TripStop> 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<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
return TripStop.fromJson(data['stop'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to record departure');
|
||||
}
|
||||
|
||||
Future<TripStop> 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<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
return TripStop.fromJson(data['stop'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to skip stop');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> 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<String, dynamic>;
|
||||
if ((res.statusCode == 200 || res.statusCode == 201) && body['success'] == true) {
|
||||
return body['data'] as Map<String, dynamic>;
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to log incident');
|
||||
}
|
||||
|
||||
Future<Trip> 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<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
return Trip.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to record dumpsite arrival');
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> releaseLoad(
|
||||
String tripUuid, {
|
||||
required int weightKg,
|
||||
String? gatePassNumber,
|
||||
String? dumpsiteAttendantName,
|
||||
Map<String, dynamic>? 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<String, dynamic>;
|
||||
if ((res.statusCode == 200 || res.statusCode == 201) && body['success'] == true) {
|
||||
return body['data'] as Map<String, dynamic>;
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to release load');
|
||||
}
|
||||
|
||||
Future<Trip> 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<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
return Trip.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to complete trip');
|
||||
}
|
||||
|
||||
Future<void> 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<String, dynamic>;
|
||||
if (res.statusCode != 200 || body['success'] != true) {
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to update truck location');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final driverRepositoryProvider = Provider<DriverRepository>((ref) {
|
||||
return DriverRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
28
lib/src/data/helper/helper_models.dart
Normal file
28
lib/src/data/helper/helper_models.dart
Normal file
@@ -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<String, dynamic> 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<String, dynamic>),
|
||||
);
|
||||
|
||||
final int id;
|
||||
final String roleInTeam;
|
||||
final String status;
|
||||
final String assignedFrom;
|
||||
final String? assignedUntil;
|
||||
final CollectionTeam team;
|
||||
}
|
||||
55
lib/src/data/helper/helper_providers.dart
Normal file
55
lib/src/data/helper/helper_providers.dart
Normal file
@@ -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<HelperAssignment?>((ref) async {
|
||||
return ref.watch(helperRepositoryProvider).fetchAssignment();
|
||||
});
|
||||
|
||||
// FutureProvider for fetching helper assignment history
|
||||
final helperHistoryProvider = FutureProvider.autoDispose<List<HelperAssignment>>((ref) async {
|
||||
return ref.watch(helperRepositoryProvider).fetchHistory();
|
||||
});
|
||||
|
||||
// StateNotifier to manage current assignment and actions
|
||||
class CurrentAssignmentNotifier extends StateNotifier<AsyncValue<HelperAssignment?>> {
|
||||
CurrentAssignmentNotifier(this._repository) : super(const AsyncValue.loading()) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
final HelperRepository _repository;
|
||||
|
||||
Future<void> 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<void> accept() async {
|
||||
try {
|
||||
final updated = await _repository.acceptAssignment();
|
||||
state = AsyncValue.data(updated);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reject() async {
|
||||
try {
|
||||
final updated = await _repository.rejectAssignment();
|
||||
state = AsyncValue.data(updated);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final currentAssignmentProvider = StateNotifierProvider.autoDispose<CurrentAssignmentNotifier, AsyncValue<HelperAssignment?>>((ref) {
|
||||
return CurrentAssignmentNotifier(ref.watch(helperRepositoryProvider));
|
||||
});
|
||||
52
lib/src/data/helper/helper_repository.dart
Normal file
52
lib/src/data/helper/helper_repository.dart
Normal file
@@ -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<HelperAssignment?> fetchAssignment() async {
|
||||
final res = await _dio.get('/helper/assignment');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
if (body['data'] == null) return null;
|
||||
return HelperAssignment.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load assignment');
|
||||
}
|
||||
|
||||
Future<HelperAssignment> acceptAssignment() async {
|
||||
final res = await _dio.post('/helper/assignment/accept');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
return HelperAssignment.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to accept assignment');
|
||||
}
|
||||
|
||||
Future<HelperAssignment> rejectAssignment() async {
|
||||
final res = await _dio.post('/helper/assignment/reject');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
return HelperAssignment.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to reject assignment');
|
||||
}
|
||||
|
||||
Future<List<HelperAssignment>> fetchHistory() async {
|
||||
final res = await _dio.get('/helper/history');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
final list = body['data'] as List;
|
||||
return list.map((a) => HelperAssignment.fromJson(a as Map<String, dynamic>)).toList();
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load history');
|
||||
}
|
||||
}
|
||||
|
||||
final helperRepositoryProvider = Provider<HelperRepository>((ref) {
|
||||
return HelperRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
@@ -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<String, dynamic>;
|
||||
|
||||
@@ -90,6 +97,21 @@ class ScanRepository {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ScannerTeamDetails> fetchTeamDetails() async {
|
||||
final res = await _dio.get('/scanner/team-details');
|
||||
final body = res.data as Map<String, dynamic>;
|
||||
if (res.statusCode == 200 && body['success'] == true) {
|
||||
final data = body['data'] as Map<String, dynamic>;
|
||||
return ScannerTeamDetails(
|
||||
team: CollectionTeam.fromJson(data['team'] as Map<String, dynamic>),
|
||||
activeTrip: data['active_trip'] != null
|
||||
? Trip.fromJson(data['active_trip'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
throw ApiException(res.statusCode ?? 0, body['message']?.toString() ?? 'Failed to load team details');
|
||||
}
|
||||
}
|
||||
|
||||
final scanRepositoryProvider = Provider<ScanRepository>((ref) {
|
||||
@@ -100,6 +122,10 @@ final scanRepositoryProvider = Provider<ScanRepository>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final scannerTeamDetailsProvider = FutureProvider.autoDispose<ScannerTeamDetails>((ref) async {
|
||||
return ref.watch(scanRepositoryProvider).fetchTeamDetails();
|
||||
});
|
||||
|
||||
/// Recent scans — in-memory ring buffer. Cleared on sign-out.
|
||||
class RecentScansController extends StateNotifier<List<ScanResult>> {
|
||||
RecentScansController() : super([]);
|
||||
|
||||
@@ -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<GoRouter>((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<GoRouter>((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'] ?? ''),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
302
lib/src/ui/driver/driver_home_screen.dart
Normal file
302
lib/src/ui/driver/driver_home_screen.dart
Normal file
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
803
lib/src/ui/driver/trip_detail_screen.dart
Normal file
803
lib/src/ui/driver/trip_detail_screen.dart
Normal file
@@ -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<TripDetailScreen> createState() => _TripDetailScreenState();
|
||||
}
|
||||
|
||||
class _TripDetailScreenState extends ConsumerState<TripDetailScreen> {
|
||||
bool _submitting = false;
|
||||
|
||||
Future<void> _runAction(Future<void> 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<void> _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<void> _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<void> _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<void> _skipStop(TripStop stop) async {
|
||||
final reasonController = TextEditingController();
|
||||
final confirm = await showDialog<bool>(
|
||||
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<void> _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<bool>(
|
||||
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<void> _releaseLoad(Trip trip) async {
|
||||
final weightController = TextEditingController();
|
||||
final attendantController = TextEditingController();
|
||||
final passController = TextEditingController();
|
||||
final notesController = TextEditingController();
|
||||
|
||||
final success = await showDialog<bool>(
|
||||
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<void> _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<void> _reportIncident() async {
|
||||
final notesController = TextEditingController();
|
||||
String kind = 'incident';
|
||||
|
||||
final logged = await showDialog<bool>(
|
||||
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<String>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
437
lib/src/ui/helper/helper_home_screen.dart
Normal file
437
lib/src/ui/helper/helper_home_screen.dart
Normal file
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ScanResult> 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<List<DopOption>> 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<Widget> 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<Offset> 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<Offset> 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;
|
||||
}
|
||||
}
|
||||
|
||||
24
pubspec.lock
24
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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user