The logistics module powers the errand and delivery flow in dfw_errand. End-users place a job; drivers pick it up; both sides see status updates as the driver moves through the lifecycle. This recipe focuses on the customer-side tracking — getting a list of in-progress jobs and showing a live status. The driver-side flow is similar but flipped (/jobs/available, accept, update location).
Endpoints
/client/logistics/initJWT/client/logistics/jobsJWT/client/logistics/jobs/:jobIdJWT/client/logistics/jobsJWT/client/logistics/jobs/:jobId/messagesJWT/client/logistics/jobs/:jobId/messagesJWTThe customer creates a job via POST /jobs, then polls GET /jobs/:jobId (or subscribes to a per-job channel) for status. Driver location updates flow through PUT /location from the driver app and propagate to the customer's job record. Full controller: /Users/imzee/projects/appengine/src/logistics/delivery-client.controller.ts.
DeliveryService
// lib/services/delivery_service.dart
import '../models/job_model.dart';
import '../services/base_model.dart';
import 'http_client.dart';
class DeliveryService {
final _http = appmintHttp;
Future<Map<String, dynamic>> initData() async {
return Map<String, dynamic>.from(
await _http.get('/client/logistics/init') as Map);
}
Future<List<JobModel>> myJobs({String? status, int page = 1}) async {
final response = await _http.get('/client/logistics/jobs', queryParams: {
'page': page.toString(),
'pageSize': '20',
if (status != null) 'status': status,
});
return BaseModel.unwrapList(response)
.map((j) => JobModel.fromJson(j as Map<String, dynamic>))
.toList();
}
Future<JobModel> jobById(String jobId) async {
final response = await _http.get('/client/logistics/jobs/$jobId');
return JobModel.fromJson(response as Map<String, dynamic>);
}
Future<JobModel> create({
required String pickupAddress,
required String dropoffAddress,
required String description,
}) async {
final response = await _http.post('/client/logistics/jobs', body: {
'pickup': {'address': pickupAddress},
'dropoff': {'address': dropoffAddress},
'description': description,
});
return JobModel.fromJson(response as Map<String, dynamic>);
}
Future<void> sendMessage(String jobId, String text) async {
await _http.post('/client/logistics/jobs/$jobId/messages', body: {
'text': text,
});
}
}
JobModel
// lib/models/job_model.dart
class JobModel {
final String pk;
final String status; // pending, accepted, picked_up, in_transit, delivered, cancelled
final String description;
final String? driverName;
final String? driverPhone;
final double? driverLat;
final double? driverLng;
final DateTime updatedAt;
JobModel({
required this.pk,
required this.status,
required this.description,
this.driverName,
this.driverPhone,
this.driverLat,
this.driverLng,
required this.updatedAt,
});
factory JobModel.fromJson(Map<String, dynamic> json) {
final data = (json['data'] ?? json) as Map<String, dynamic>;
final driver = data['driver'] is Map ? data['driver'] as Map : null;
final location = driver?['location'] is Map
? driver!['location'] as Map
: null;
return JobModel(
pk: (json['pk'] ?? '').toString(),
status: (data['status'] ?? 'pending').toString(),
description: (data['description'] ?? '').toString(),
driverName: driver?['name'] as String?,
driverPhone: driver?['phone'] as String?,
driverLat: (location?['lat'] as num?)?.toDouble(),
driverLng: (location?['lng'] as num?)?.toDouble(),
updatedAt: DateTime.tryParse(json['modifydate'] ?? '') ??
DateTime.now(),
);
}
}
Tracking screen
A page that polls the job every 5 seconds while it's in flight. Stop polling when the status hits a terminal state (delivered, cancelled).
// lib/screens/delivery/tracking_screen.dart
import 'dart:async';
import 'package:flutter/material.dart';
import '../../models/job_model.dart';
import '../../services/delivery_service.dart';
class TrackingScreen extends StatefulWidget {
final String jobId;
const TrackingScreen({super.key, required this.jobId});
@override
State<TrackingScreen> createState() => _TrackingScreenState();
}
class _TrackingScreenState extends State<TrackingScreen> {
final _service = DeliveryService();
Timer? _timer;
JobModel? _job;
String? _error;
static const _terminal = {'delivered', 'cancelled', 'failed'};
@override
void initState() {
super.initState();
_refresh();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => _refresh());
}
Future<void> _refresh() async {
try {
final job = await _service.jobById(widget.jobId);
if (!mounted) return;
setState(() {
_job = job;
_error = null;
});
if (_terminal.contains(job.status)) _timer?.cancel();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Tracking')),
body: _job == null
? Center(
child: _error != null
? Text(_error!)
: const CircularProgressIndicator(),
)
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_StatusPill(status: _job!.status),
const SizedBox(height: 24),
Text(_job!.description,
style: Theme.of(context).textTheme.titleMedium),
const Divider(height: 32),
if (_job!.driverName != null) ...[
Text('Driver: ${_job!.driverName}'),
if (_job!.driverPhone != null)
Text('Phone: ${_job!.driverPhone}'),
] else
const Text('Waiting for a driver...'),
const SizedBox(height: 24),
if (_job!.driverLat != null && _job!.driverLng != null)
Text(
'Driver location: ${_job!.driverLat!.toStringAsFixed(4)}, '
'${_job!.driverLng!.toStringAsFixed(4)}',
),
],
),
),
);
}
}
class _StatusPill extends StatelessWidget {
final String status;
const _StatusPill({required this.status});
@override
Widget build(BuildContext context) {
final color = _colorFor(status);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
),
child: Text(
_humanize(status),
style: TextStyle(color: color, fontWeight: FontWeight.w600),
),
);
}
Color _colorFor(String s) {
switch (s) {
case 'pending':
return Colors.grey;
case 'accepted':
case 'picked_up':
case 'in_transit':
return Colors.blue;
case 'delivered':
return Colors.green;
case 'cancelled':
case 'failed':
return Colors.red;
default:
return Colors.grey;
}
}
String _humanize(String s) =>
s.replaceAll('_', ' ').replaceFirstMapped(
RegExp(r'^.'),
(m) => m.group(0)!.toUpperCase(),
);
}
Polling vs WebSocket
Polling at 5 seconds is fine for most delivery experiences — drivers don't move pixel-by-pixel and users don't need sub-second precision. If your app needs live map updates (Uber-style), wire a WebSocket to AppEngine's logistics gateway (or use Firebase Realtime Database for the live coordinate stream and let logistics handle status).
The dfw_errand app uses polling exclusively. The 5-second cadence is a reasonable default; reduce to 2-3 seconds while a driver is actively moving (status in_transit) and back off to 10-15 seconds for waiting states.
Stopping the timer
Always cancel the timer in dispose() — a leaked timer keeps making HTTP calls after the user has navigated away.
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
If the user backgrounds the app, Timer.periodic keeps running unless you intercept lifecycle. Use a WidgetsBindingObserver to pause polling on AppLifecycleState.paused and resume on resumed if you care.
Map view
For a real map, drop in google_maps_flutter and place a marker on the driver's lat/lng. The pattern:
dependencies:
google_maps_flutter: ^2.10.0
GoogleMap(
initialCameraPosition: CameraPosition(
target: LatLng(_job!.driverLat!, _job!.driverLng!),
zoom: 14,
),
markers: {
Marker(
markerId: const MarkerId('driver'),
position: LatLng(_job!.driverLat!, _job!.driverLng!),
),
},
);
You'll need API keys configured in ios/Runner/AppDelegate.swift and android/app/src/main/AndroidManifest.xml per the package docs.
In-job messaging
Customer and driver can exchange messages on the job thread:
await DeliveryService().sendMessage(jobId, 'I am at the front door.');
final messages = await appmintHttp.get('/client/logistics/jobs/$jobId/messages');
For real-time, the chat gateway covered in chat-with-customer-support handles per-job rooms — pass the jobId as the chatId on connect.
The next recipe — Lead management (admin) — switches to the operator side.