Documentation

Delivery tracking

Real-time job status from AppEngine's logistics module, taken from dfw_errand.

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

GET/client/logistics/initJWT
GET/client/logistics/jobsJWT
GET/client/logistics/jobs/:jobIdJWT
POST/client/logistics/jobsJWT
GET/client/logistics/jobs/:jobId/messagesJWT
POST/client/logistics/jobs/:jobId/messagesJWT

The 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.