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>
This commit is contained in:
460
lib/ui/widgets/route_map.dart
Normal file
460
lib/ui/widgets/route_map.dart
Normal file
@@ -0,0 +1,460 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user