Files
doormile_customer_app/lib/ui/widgets/map_panel.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

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,
),
),
),
),
);
}
}