Files
Verde-Web/app/Http/Controllers/Api/V1/Driver/DriverTripController.php
admin b1bc73b100 feat(mobile-driver): Phase 2 — trip lifecycle + foreground GPS
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>
2026-05-01 23:36:43 +08:00

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