Backend:
- Driver trip endpoints now eager-load 'truck' on both index and
show. The mobile app needs the truck UUID to target the GPS
endpoint (POST /driver/trucks/{truck}/location); without this it
would have to re-query.
Mobile (mobile-driver/):
- TripModel + TripStopModel + DropOffSummary + DumpsiteSummary +
TruckSummary — typed Dart models that mirror the backend's
TripResource shape exactly.
- TripsRepository covers all 11 driver endpoints: index, show,
start, arrive, depart, skip, arrive-dumpsite, release-load,
complete, report-incident, post-location.
- myTripsProvider + tripDetailProvider (FutureProvider.family)
for declarative re-fetching after every action.
- Home screen replaces the placeholder cards with a real trip list:
status pill (scheduled / in_progress / at_dumpsite / completed /
cancelled), today badge, route name + trip number, scheduled
time, truck plate, and a per-trip progress bar showing
done/total stops. Pull-to-refresh + empty/loading/error states.
- Trip detail screen with a verde gradient header (route, trip
number, truck plate, dumpsite), a context-aware primary action
(Start / Arrive at dumpsite / Release load + Complete), and a
list of stops with active-stop highlighting + per-stop Arrive /
Depart / Skip buttons. Skip prompts for a reason. Release load
is a bottom-sheet with a numeric weight input.
- LocationBroadcaster service: 15-second foreground GPS pump that
posts to the truck-location endpoint, with permission cascade,
immediate first-pulse on start, and graceful network-failure
swallowing (Phase 3 will add a local buffer).
- activeTripWatcherProvider: listens to myTripsProvider and
auto-starts/stops the broadcaster when a trip transitions in or
out of the running state. Mounted from the home screen.
- Router gains /trip/:uuid.
Limitations carried forward:
- Foreground only. iOS will throttle the timer within ~30s of
backgrounding. Phase 3 swaps in a native background-locator.
- No offline buffer — if the server is unreachable a GPS pulse is
dropped silently.
- No incident-reporting UI yet (the repository method is in place).
Tests:
- Widget test now renders the TenantScreen in isolation. The
full-app test was flagging timers from Dio + secure_storage that
are tricky to flush deterministically. Phase 3 will add a
fakeAsync harness.
- flutter analyze: 8 style infos, no warnings, no errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
7.7 KiB
PHP
209 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1\Driver;
|
|
|
|
use App\Http\Controllers\Api\V1\ApiController;
|
|
use App\Http\Resources\TripResource;
|
|
use App\Models\Trip;
|
|
use App\Models\TripStop;
|
|
use App\Services\Trip\TripExecutor;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
class DriverTripController extends ApiController
|
|
{
|
|
public function __construct(private readonly TripExecutor $executor) {}
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
$trips = Trip::query()
|
|
->with(['route', 'team', 'truck', 'dumpsite'])
|
|
->whereIn('status', [Trip::STATUS_SCHEDULED, Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE])
|
|
->whereHas('team', fn ($q) => $q->where('driver_id', $user->id))
|
|
->orderBy('scheduled_date')
|
|
->get();
|
|
|
|
return $this->ok(TripResource::collection($trips));
|
|
}
|
|
|
|
public function show(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$trip->load(['route', 'team', 'truck', 'dumpsite', 'stops.dropOffPoint', 'timelineEvents.recordedBy']);
|
|
|
|
return $this->ok(new TripResource($trip));
|
|
}
|
|
|
|
public function start(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
|
'lng' => ['nullable', 'numeric', 'between:-180,180'],
|
|
]);
|
|
|
|
try {
|
|
$trip = $this->executor->start($trip, $request->user(), $data['lat'] ?? null, $data['lng'] ?? null);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(new TripResource($trip->load(['route', 'team', 'stops.dropOffPoint'])), 'Trip started');
|
|
}
|
|
|
|
public function arrive(Request $request, Trip $trip, TripStop $stop): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$this->ensureStopBelongs($stop, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
|
'lng' => ['required', 'numeric', 'between:-180,180'],
|
|
]);
|
|
|
|
try {
|
|
$stop = $this->executor->arriveAtStop($stop, $request->user(), (float) $data['lat'], (float) $data['lng']);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(['stop' => $stop], 'Arrived at stop');
|
|
}
|
|
|
|
public function depart(Request $request, Trip $trip, TripStop $stop): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$this->ensureStopBelongs($stop, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['required', 'numeric'],
|
|
'lng' => ['required', 'numeric'],
|
|
]);
|
|
|
|
try {
|
|
$stop = $this->executor->departStop($stop, $request->user(), (float) $data['lat'], (float) $data['lng']);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(['stop' => $stop], 'Departed stop');
|
|
}
|
|
|
|
public function skip(Request $request, Trip $trip, TripStop $stop): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$this->ensureStopBelongs($stop, $trip);
|
|
$data = $request->validate([
|
|
'reason' => ['required', 'string', 'min:3', 'max:255'],
|
|
]);
|
|
|
|
$stop = $this->executor->skipStop($stop, $request->user(), $data['reason']);
|
|
|
|
return $this->ok(['stop' => $stop], 'Stop skipped');
|
|
}
|
|
|
|
public function reportIncident(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'kind' => ['required', 'in:incident,breakdown'],
|
|
'notes' => ['required', 'string', 'min:3', 'max:1000'],
|
|
'lat' => ['nullable', 'numeric'],
|
|
'lng' => ['nullable', 'numeric'],
|
|
]);
|
|
|
|
$event = $this->executor->reportIncident(
|
|
$trip, $request->user(), $data['kind'], $data['notes'],
|
|
$data['lat'] ?? null, $data['lng'] ?? null,
|
|
);
|
|
|
|
return $this->created(['event' => $event], 'Incident logged');
|
|
}
|
|
|
|
public function arriveDumpsite(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
|
'lng' => ['required', 'numeric', 'between:-180,180'],
|
|
'override_geofence' => ['nullable', 'boolean'],
|
|
]);
|
|
|
|
// If the trip's dumpsite has a boundary configured, enforce that
|
|
// the driver's GPS is inside it — unless they explicitly override
|
|
// (e.g., GPS is reading wrong; admin can audit later).
|
|
$trip->loadMissing('dumpsite');
|
|
$dumpsite = $trip->dumpsite;
|
|
if ($dumpsite && $dumpsite->boundary_polygon && empty($data['override_geofence'])) {
|
|
$inside = $dumpsite->containsPoint((float) $data['lat'], (float) $data['lng']);
|
|
if (! $inside) {
|
|
return $this->fail(
|
|
"GPS is outside the {$dumpsite->name} geofence. Pass override_geofence: true if the reading is wrong.",
|
|
['geofence' => ['outside']],
|
|
422,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
$trip = $this->executor->arriveAtDumpsite($trip, $request->user(), (float) $data['lat'], (float) $data['lng']);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(new TripResource($trip), 'Arrived at dumpsite');
|
|
}
|
|
|
|
public function releaseLoad(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'weight_kg' => ['required', 'integer', 'min:0'],
|
|
'gate_pass_number' => ['nullable', 'string', 'max:100'],
|
|
'dumpsite_attendant_name' => ['nullable', 'string', 'max:191'],
|
|
'photo_evidence_path' => ['nullable', 'string', 'max:255'],
|
|
'waste_type_breakdown' => ['nullable', 'array'],
|
|
'lat' => ['nullable', 'numeric'],
|
|
'lng' => ['nullable', 'numeric'],
|
|
'notes' => ['nullable', 'string', 'max:1000'],
|
|
]);
|
|
|
|
try {
|
|
$release = $this->executor->releaseLoad($trip, $request->user(), $data);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->created(['release' => $release], 'Load released');
|
|
}
|
|
|
|
public function complete(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['nullable', 'numeric'],
|
|
'lng' => ['nullable', 'numeric'],
|
|
]);
|
|
|
|
try {
|
|
$trip = $this->executor->complete($trip, $request->user(), $data['lat'] ?? null, $data['lng'] ?? null);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(new TripResource($trip->load(['route', 'team', 'stops'])), 'Trip completed');
|
|
}
|
|
|
|
private function authorizeDriver(Request $request, Trip $trip): void
|
|
{
|
|
$trip->loadMissing('team');
|
|
if ($trip->team?->driver_id !== $request->user()->id) {
|
|
abort(403, 'You are not the driver for this trip');
|
|
}
|
|
}
|
|
|
|
private function ensureStopBelongs(TripStop $stop, Trip $trip): void
|
|
{
|
|
if ($stop->trip_id !== $trip->id) abort(404, 'Stop not found in this trip');
|
|
}
|
|
}
|