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