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>
455 lines
13 KiB
Dart
455 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:math' as math;
|
|
|
|
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 '../tokens.dart';
|
|
import 'map_tiles.dart';
|
|
|
|
/// The pickup map: a real, pannable map with a pin fixed to the centre.
|
|
///
|
|
/// The pin never moves — the map moves under it, the way every ride-hailing
|
|
/// app does it. When the map settles, [onPinMoved] fires with the coordinates
|
|
/// under the pin so the caller can reverse-geocode them.
|
|
class DmMapPanel extends StatefulWidget {
|
|
const DmMapPanel({
|
|
super.key,
|
|
required this.lat,
|
|
required this.lng,
|
|
this.height,
|
|
this.loading = false,
|
|
this.interactive = true,
|
|
this.zoom = 16.5,
|
|
this.deviceLat,
|
|
this.deviceLng,
|
|
this.deviceAccuracy,
|
|
this.onLocate,
|
|
this.onPinMoveStart,
|
|
this.onPinMoved,
|
|
this.borderRadius = const BorderRadius.all(Radius.circular(DmRadius.lg)),
|
|
});
|
|
|
|
/// Where the pin sits. Changing these recentres the map, unless the customer
|
|
/// is mid-gesture — their drag always wins.
|
|
final double lat;
|
|
final double lng;
|
|
|
|
/// Fixed height, or null to fill whatever the parent gives it — which is how
|
|
/// it sits inside the pickup card.
|
|
final double? height;
|
|
|
|
/// True while we are still resolving a position; dims the pin.
|
|
final bool loading;
|
|
|
|
/// False makes the map a picture: no dragging, no pin lift, no callbacks.
|
|
final bool interactive;
|
|
|
|
final double zoom;
|
|
|
|
/// Where the customer is, drawn as the blue dot. Null hides it — a refused
|
|
/// permission should show no dot rather than a dot in the wrong place.
|
|
///
|
|
/// Distinct from [lat]/[lng], which is where the *pickup pin* is: after a
|
|
/// drag the two separate, which is exactly the information the dot carries.
|
|
final double? deviceLat;
|
|
final double? deviceLng;
|
|
|
|
/// Horizontal accuracy in metres. Drawn as a halo at true map scale, so it
|
|
/// shrinks as the customer zooms in. Null draws the dot alone.
|
|
final double? deviceAccuracy;
|
|
|
|
/// Shows the "my location" control when set.
|
|
final VoidCallback? onLocate;
|
|
|
|
/// Fires the moment a drag begins, so the address can go stale-quiet.
|
|
final VoidCallback? onPinMoveStart;
|
|
|
|
/// Fires once the map has come to rest under the pin.
|
|
final void Function(double lat, double lng)? onPinMoved;
|
|
|
|
final BorderRadius borderRadius;
|
|
|
|
@override
|
|
State<DmMapPanel> createState() => _DmMapPanelState();
|
|
}
|
|
|
|
class _DmMapPanelState extends State<DmMapPanel> with TickerProviderStateMixin {
|
|
final MapController _map = MapController();
|
|
|
|
/// Raises the pin off the map while the customer drags.
|
|
late final AnimationController _lift = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 180),
|
|
);
|
|
|
|
late final AnimationController _glide = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 420),
|
|
);
|
|
|
|
Timer? _settle;
|
|
bool _ready = false;
|
|
bool _dragging = false;
|
|
|
|
/// The last position the parent knows about — either what it handed us or
|
|
/// what we last reported. Used to tell an external recentre from an echo of
|
|
/// our own callback.
|
|
late LatLng _known = LatLng(widget.lat, widget.lng);
|
|
|
|
@override
|
|
void dispose() {
|
|
_settle?.cancel();
|
|
_lift.dispose();
|
|
_glide.dispose();
|
|
_map.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant DmMapPanel old) {
|
|
super.didUpdateWidget(old);
|
|
if (widget.lat == old.lat && widget.lng == old.lng) return;
|
|
|
|
final target = LatLng(widget.lat, widget.lng);
|
|
// Our own reverse-geocode coming back, or a genuinely new place?
|
|
if (_metres(target, _known) < 20) {
|
|
_known = target;
|
|
return;
|
|
}
|
|
_known = target;
|
|
if (!_dragging) _glideTo(target);
|
|
}
|
|
|
|
/// flutter_map moves instantly; a short eased glide keeps "locate me" and a
|
|
/// searched address from teleporting.
|
|
void _glideTo(LatLng target) {
|
|
if (!_ready) return;
|
|
final from = _map.camera.center;
|
|
final fromZoom = _map.camera.zoom;
|
|
_glide
|
|
..stop()
|
|
..reset();
|
|
|
|
void tick() {
|
|
final t = Curves.easeInOutCubic.transform(_glide.value);
|
|
_map.move(
|
|
LatLng(
|
|
from.latitude + (target.latitude - from.latitude) * t,
|
|
from.longitude + (target.longitude - from.longitude) * t,
|
|
),
|
|
fromZoom + (widget.zoom - fromZoom) * t,
|
|
);
|
|
}
|
|
|
|
_glide.addListener(tick);
|
|
_glide.forward().whenComplete(() => _glide.removeListener(tick));
|
|
}
|
|
|
|
void _onEvent(MapEvent event) {
|
|
if (!widget.interactive) return;
|
|
|
|
if (event is MapEventMoveStart ||
|
|
event is MapEventFlingAnimationStart ||
|
|
event is MapEventDoubleTapZoomStart) {
|
|
_settle?.cancel();
|
|
if (!_dragging) {
|
|
_dragging = true;
|
|
_lift.forward();
|
|
widget.onPinMoveStart?.call();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event is MapEventMoveEnd ||
|
|
event is MapEventFlingAnimationEnd ||
|
|
event is MapEventDoubleTapZoomEnd) {
|
|
_dragging = false;
|
|
_lift.reverse();
|
|
final centre = event.camera.center;
|
|
// A flick that ends and restarts should resolve once, not twice.
|
|
_settle?.cancel();
|
|
_settle = Timer(const Duration(milliseconds: 320), () {
|
|
if (!mounted) return;
|
|
_known = centre;
|
|
widget.onPinMoved?.call(centre.latitude, centre.longitude);
|
|
});
|
|
}
|
|
}
|
|
|
|
static double _metres(LatLng a, LatLng b) {
|
|
const earth = 6371000.0;
|
|
double rad(double d) => d * math.pi / 180;
|
|
final dLat = rad(b.latitude - a.latitude);
|
|
final dLng = rad(b.longitude - a.longitude);
|
|
final h = math.sin(dLat / 2) * math.sin(dLat / 2) +
|
|
math.cos(rad(a.latitude)) *
|
|
math.cos(rad(b.latitude)) *
|
|
math.sin(dLng / 2) *
|
|
math.sin(dLng / 2);
|
|
return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
|
|
}
|
|
|
|
/// The customer's own position: an accuracy halo at map scale with the dot
|
|
/// on top. Returns null when there is no fix to draw.
|
|
Widget? get _deviceLayers {
|
|
final lat = widget.deviceLat;
|
|
final lng = widget.deviceLng;
|
|
if (lat == null || lng == null) return null;
|
|
final at = LatLng(lat, lng);
|
|
final accuracy = widget.deviceAccuracy;
|
|
|
|
return Stack(
|
|
children: [
|
|
if (accuracy != null && accuracy > 0)
|
|
CircleLayer(
|
|
circles: [
|
|
CircleMarker(
|
|
point: at,
|
|
// True metres, so the halo tells the truth at every zoom.
|
|
radius: accuracy,
|
|
useRadiusInMeter: true,
|
|
color: DmColors.locationHalo,
|
|
borderColor: DmColors.locationDot.withValues(alpha: 0.28),
|
|
borderStrokeWidth: 1,
|
|
),
|
|
],
|
|
),
|
|
MarkerLayer(
|
|
markers: [
|
|
Marker(
|
|
key: const ValueKey('deviceLocationDot'),
|
|
point: at,
|
|
width: 22,
|
|
height: 22,
|
|
child: const _DeviceDot(),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final map = ClipRRect(
|
|
borderRadius: widget.borderRadius,
|
|
child: ColoredBox(
|
|
color: DmMapTiles.ground,
|
|
child: Stack(
|
|
children: [
|
|
Positioned.fill(
|
|
child: FlutterMap(
|
|
mapController: _map,
|
|
options: MapOptions(
|
|
initialCenter: LatLng(widget.lat, widget.lng),
|
|
initialZoom: widget.zoom,
|
|
minZoom: 4,
|
|
maxZoom: 19,
|
|
backgroundColor: DmMapTiles.ground,
|
|
onMapReady: () => _ready = true,
|
|
onMapEvent: _onEvent,
|
|
interactionOptions: InteractionOptions(
|
|
// North stays up: rotation buys nothing here and makes
|
|
// the fixed pin read as broken.
|
|
flags: widget.interactive
|
|
? InteractiveFlag.drag |
|
|
InteractiveFlag.flingAnimation |
|
|
InteractiveFlag.pinchMove |
|
|
InteractiveFlag.pinchZoom |
|
|
InteractiveFlag.doubleTapZoom
|
|
: InteractiveFlag.none,
|
|
),
|
|
),
|
|
children: [
|
|
DmMapTiles.layer(context),
|
|
?_deviceLayers,
|
|
],
|
|
),
|
|
),
|
|
|
|
// The pin is an overlay, not a marker: it belongs to the screen,
|
|
// not to a coordinate.
|
|
Center(
|
|
child: IgnorePointer(
|
|
child: AnimatedOpacity(
|
|
opacity: widget.loading ? 0.45 : 1,
|
|
duration: DmMotion.base,
|
|
child: AnimatedBuilder(
|
|
animation: _lift,
|
|
builder: (context, child) => _CentrePin(lift: _lift.value),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
if (widget.onLocate != null)
|
|
Positioned(
|
|
right: 12,
|
|
bottom: 12,
|
|
child: _LocateButton(
|
|
busy: widget.loading,
|
|
onTap: widget.onLocate!,
|
|
),
|
|
),
|
|
|
|
const Positioned(left: 10, bottom: 10, child: DmMapAttribution()),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
return widget.height == null
|
|
? map
|
|
: SizedBox(height: widget.height, child: map);
|
|
}
|
|
}
|
|
|
|
/// The fixed centre pin. [lift] 0..1 raises it and shrinks its shadow, so a
|
|
/// drag feels like picking the pin up rather than dragging the ground.
|
|
class _CentrePin extends StatelessWidget {
|
|
const _CentrePin({required this.lift});
|
|
|
|
final double lift;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final eased = Curves.easeOutCubic.transform(lift);
|
|
return SizedBox(
|
|
width: 60,
|
|
height: 74,
|
|
child: Stack(
|
|
alignment: Alignment.bottomCenter,
|
|
children: [
|
|
// Ground shadow — the anchor point the pin actually refers to.
|
|
Positioned(
|
|
bottom: 8,
|
|
child: Transform.scale(
|
|
scale: 1 - eased * 0.35,
|
|
child: Container(
|
|
width: 18,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: DmColors.ink.withValues(alpha: 0.22 - eased * 0.1),
|
|
borderRadius: BorderRadius.circular(3),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 10 + eased * 12,
|
|
child: const _PinHead(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "You are here". White ring so it stays legible over any cartography, and a
|
|
/// soft shadow so it sits on the map rather than in it.
|
|
class _DeviceDot extends StatelessWidget {
|
|
const _DeviceDot();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Container(
|
|
width: 18,
|
|
height: 18,
|
|
decoration: BoxDecoration(
|
|
color: DmColors.locationDot,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: Colors.white, width: 3),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x3D14100F),
|
|
blurRadius: 6,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PinHead extends StatelessWidget {
|
|
const _PinHead();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return const Stack(
|
|
alignment: Alignment.topCenter,
|
|
children: [
|
|
Icon(
|
|
LucideIcons.mapPin,
|
|
size: 46,
|
|
color: DmColors.brand,
|
|
shadows: [
|
|
Shadow(
|
|
color: Color(0x408F0F06),
|
|
blurRadius: 12,
|
|
offset: Offset(0, 5),
|
|
),
|
|
],
|
|
),
|
|
Positioned(
|
|
top: 12,
|
|
child: CircleAvatar(radius: 5.5, backgroundColor: Colors.white),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LocateButton extends StatelessWidget {
|
|
const _LocateButton({required this.onTap, this.busy = false});
|
|
|
|
final VoidCallback onTap;
|
|
final bool busy;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
boxShadow: DmShadow.float,
|
|
),
|
|
child: Material(
|
|
color: DmColors.surface,
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
child: InkWell(
|
|
onTap: busy ? null : onTap,
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
child: Container(
|
|
width: DmSpace.tap,
|
|
height: DmSpace.tap,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
border: Border.all(color: DmColors.line),
|
|
),
|
|
child: busy
|
|
? const SizedBox(
|
|
width: 18,
|
|
height: 18,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: DmColors.brand,
|
|
),
|
|
)
|
|
: const Icon(
|
|
LucideIcons.locateFixed,
|
|
size: 22,
|
|
color: DmColors.ink,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|