Files
doormile_customer_app/lib/ui/widgets/route_map.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

461 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../data/location_service.dart';
import '../../data/map_config.dart';
import '../../data/models.dart';
import '../tokens.dart';
import 'map_tiles.dart';
/// A real map of the journey: pickup and destination joined by the transit
/// path, with the Miler's live position when there is one.
///
/// Read-only by design — it summarises a booking, and a non-interactive map
/// never steals a scroll gesture from the list it sits in.
class DmRouteMap extends StatefulWidget {
const DmRouteMap({
super.key,
this.height = 190,
this.pickup,
this.destination,
this.progress = 0,
this.showMiler = false,
this.pickupLabel,
this.destinationLabel,
this.badge,
this.overlay,
this.radius = DmRadius.lg,
});
final double height;
/// Where the Miler collects. Without coordinates the map falls back to the
/// service-area centre rather than showing an empty ocean.
final Place? pickup;
/// The destination hub. Null draws pickup only — which is exactly right
/// before a district has been chosen.
final Place? destination;
/// How far along the route the Miler is, 0..1.
final double progress;
final bool showMiler;
final String? pickupLabel;
final String? destinationLabel;
/// Pill shown top-left, e.g. "En route · 1.4 km away".
final Widget? badge;
/// Anything centred over the map, e.g. the "searching" state.
final Widget? overlay;
final double radius;
@override
State<DmRouteMap> createState() => _DmRouteMapState();
}
class _DmRouteMapState extends State<DmRouteMap>
with TickerProviderStateMixin {
late final AnimationController _pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat();
late final AnimationController _draw = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..forward();
@override
void dispose() {
_pulse.dispose();
_draw.dispose();
super.dispose();
}
LatLng? get _from => widget.pickup != null && widget.pickup!.hasLocation
? LatLng(widget.pickup!.lat!, widget.pickup!.lng!)
: null;
LatLng? get _to =>
widget.destination != null && widget.destination!.hasLocation
? LatLng(widget.destination!.lat!, widget.destination!.lng!)
: null;
/// A gentle arc rather than a ruler-straight line — it reads as a journey,
/// and it separates the two ends when they are nearly aligned.
List<LatLng> _arc(LatLng a, LatLng b) {
final dLat = b.latitude - a.latitude;
final dLng = b.longitude - a.longitude;
// Perpendicular offset, proportional to the span.
final bowLat = -dLng * 0.12;
final bowLng = dLat * 0.12;
final control = LatLng(
(a.latitude + b.latitude) / 2 + bowLat,
(a.longitude + b.longitude) / 2 + bowLng,
);
return [
for (var i = 0; i <= 48; i++)
_quadratic(a, control, b, i / 48),
];
}
static LatLng _quadratic(LatLng a, LatLng c, LatLng b, double t) {
final u = 1 - t;
return LatLng(
u * u * a.latitude + 2 * u * t * c.latitude + t * t * b.latitude,
u * u * a.longitude + 2 * u * t * c.longitude + t * t * b.longitude,
);
}
@override
Widget build(BuildContext context) {
final from = _from;
final to = _to;
final route = (from != null && to != null) ? _arc(from, to) : null;
return ClipRRect(
borderRadius: DmRadius.all(widget.radius),
child: SizedBox(
height: widget.height,
child: ColoredBox(
color: DmMapTiles.ground,
child: Stack(
children: [
Positioned.fill(
child: _map(context, from: from, to: to, route: route),
),
if (widget.badge != null)
Positioned(
top: 12,
left: 12,
// Held to half the map's width so it cannot run under the
// attribution chip opposite it, whatever the badge says.
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.5,
),
child: widget.badge!,
),
),
if (widget.pickupLabel != null)
Positioned(
left: 12,
bottom: 12,
child: _MapLabel(
icon: LucideIcons.circleDot,
color: DmColors.ink,
label: widget.pickupLabel!,
),
),
if (widget.destinationLabel != null)
Positioned(
right: 12,
bottom: 12,
child: _MapLabel(
icon: LucideIcons.mapPin,
color: DmColors.brand,
label: widget.destinationLabel!,
),
),
// Attribution is required on every map, so it sits top-right,
// clear of the badge (top-left) and the labels (bottom).
const Positioned(top: 10, right: 10, child: DmMapAttribution()),
if (widget.overlay != null) Center(child: widget.overlay!),
],
),
),
),
);
}
Widget _map(
BuildContext context, {
required LatLng? from,
required LatLng? to,
required List<LatLng>? route,
}) {
final centre =
from ??
to ??
const LatLng(
LocationService.fallbackLat,
LocationService.fallbackLng,
);
// The map itself is built once. Only the layers that animate sit inside an
// AnimatedBuilder, so a pulsing Miler never re-creates the camera.
return FlutterMap(
options: MapOptions(
initialCenter: centre,
initialZoom: to == null ? 13.5 : 11,
initialCameraFit: route == null
? null
: CameraFit.coordinates(
coordinates: route,
padding: const EdgeInsets.fromLTRB(46, 34, 46, 52),
maxZoom: 14,
),
backgroundColor: DmMapTiles.ground,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.none,
),
),
children: [
// The quieter basemap: this map is a summary, the route is the subject.
DmMapTiles.layer(context, style: DmMapStyle.muted),
if (route != null)
AnimatedBuilder(
animation: _draw,
builder: (context, _) {
final drawn = Curves.easeOutCubic.transform(_draw.value);
final upto = (route.length * drawn).ceil().clamp(2, route.length);
return PolylineLayer(
polylines: [
Polyline(
points: route.sublist(0, upto),
color: DmColors.brand,
strokeWidth: 4,
borderColor: Colors.white,
borderStrokeWidth: 3,
),
],
);
},
),
AnimatedBuilder(
animation: widget.showMiler
? Listenable.merge([_pulse, _draw])
: _draw,
builder: (context, _) {
final drawn = Curves.easeOutCubic.transform(_draw.value);
return MarkerLayer(
markers: [
if (from != null)
Marker(
point: from,
width: 26,
height: 26,
child: const _OriginDot(),
),
if (to != null)
Marker(
point: to,
width: 34,
height: 40,
alignment: Alignment.topCenter,
child: const _DestinationPin(),
),
if (widget.showMiler && route != null)
Marker(
point: route[(
(route.length - 1) *
widget.progress.clamp(0.02, 0.98) *
drawn
).round()],
width: 58,
height: 58,
child: _MilerDot(pulse: _pulse.value),
),
],
);
},
),
],
);
}
}
class _OriginDot extends StatelessWidget {
const _OriginDot();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: DmColors.ink, width: 3.5),
boxShadow: DmShadow.float,
),
),
);
}
}
class _DestinationPin extends StatelessWidget {
const _DestinationPin();
@override
Widget build(BuildContext context) {
return const Stack(
alignment: Alignment.topCenter,
children: [
Icon(
LucideIcons.mapPin,
size: 34,
color: DmColors.brand,
shadows: [
Shadow(color: Color(0x338F0F06), blurRadius: 8, offset: Offset(0, 4)),
],
),
Positioned(
top: 9,
child: CircleAvatar(radius: 4, backgroundColor: Colors.white),
),
],
);
}
}
/// The Miler's live position. The expanding ring is what makes it read as live.
class _MilerDot extends StatelessWidget {
const _MilerDot({required this.pulse});
final double pulse;
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 24 + pulse * 30,
height: 24 + pulse * 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: DmColors.brand.withValues(alpha: (1 - pulse) * 0.22),
),
),
Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: DmShadow.float,
),
child: const Icon(
LucideIcons.bike,
size: 15,
color: DmColors.brand,
),
),
],
),
);
}
}
class _MapLabel extends StatelessWidget {
const _MapLabel({required this.icon, required this.color, required this.label});
final IconData icon;
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 168),
padding: const EdgeInsets.fromLTRB(9, 6, 12, 6),
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(6),
boxShadow: DmShadow.float,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 12),
),
),
],
),
);
}
}
/// Radar sweep shown while the backend is matching a Miler.
class DmRadarPulse extends StatefulWidget {
const DmRadarPulse({super.key, this.size = 150, this.child});
final double size;
final Widget? child;
@override
State<DmRadarPulse> createState() => _DmRadarPulseState();
}
class _DmRadarPulseState extends State<DmRadarPulse>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2600),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _c,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
for (var i = 0; i < 3; i++) _ring(i),
?child,
],
);
},
child: widget.child,
),
);
}
Widget _ring(int index) {
final t = (_c.value + index / 3) % 1;
return Opacity(
opacity: (1 - t) * 0.5,
child: Container(
width: widget.size * (0.35 + t * 0.65),
height: widget.size * (0.35 + t * 0.65),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: DmColors.brand.withValues(alpha: 0.45),
width: 1.5,
),
),
),
);
}
}