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>
461 lines
13 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|