Implement helper dashboard, providers, assignment check-in, and routing in mobile app

This commit is contained in:
Developer
2026-06-29 16:57:29 +08:00
parent 240c5f7adc
commit 984ce99c72
18 changed files with 2914 additions and 73 deletions

View File

@@ -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;

View File

@@ -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 {

View 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;
});

View 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;
}

View 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),
);
});

View 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));
});

View 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;
}

View 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));
});

View 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));
});

View File

@@ -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([]);

View File

@@ -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'] ?? ''),
),
],
);
});

View File

@@ -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),
),

View 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'),
),
],
),
),
);
}
}

View 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,
);
}
}

View 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),
),
),
],
),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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:

View File

@@ -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: