Files
doormile_milderapp/lib/views/helpers/widgets/miler_map.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

594 lines
20 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' show LatLng;
import 'package:miler/helpers/miler_router.dart';
/// The router travels with the map: every screen that draws one also asks for a
/// route, and neither is any use without the other.
export 'package:miler/helpers/miler_router.dart' show MilerRouter;
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// MAPS, WITHOUT A KEY
///
/// Every map in this app was a `GoogleMap`, and every one of them rendered
/// blank: the key the app shipped with is revoked, so the tile requests, the
/// marker layer and the Directions call all failed at once. A blank grey
/// rectangle where the route should be is indistinguishable from a broken app,
/// and no amount of Dart fixes it — the failure is entirely in credentials.
///
/// So the maps no longer need credentials.
///
/// • **Tiles** — OpenStreetMap raster tiles, served free and keyless, drawn by
/// `flutter_map`. Nothing to provision, nothing to revoke.
/// • **Routes** — OSRM's public router, also free and keyless, in place of the
/// Directions API. It returns the same thing the app used: a driving
/// geometry between two points.
///
/// ── What this costs, and what it buys ──
///
/// Both services are community-run and neither is contracted for production
/// traffic: OSM's tile policy and OSRM's demo server are fine for development
/// and a small fleet, and will rate-limit a large one. Swapping either is one
/// constant ([kMilerTileUrl]) and one function ([MilerRouter.route]) — that is
/// why they are here rather than inlined at four call sites. See the note in
/// README before shipping to a real fleet.
///
/// What it buys immediately: the map is visible again on every screen, and
/// markers stop being textures. A Google Maps marker is a `BitmapDescriptor` —
/// a bitmap the app has to *paint on a canvas* to put a number in a circle
/// (`multi_map.dart` had 100 lines doing exactly that). Here a marker is a
/// widget, so it is drawn with the same colours, fonts and radii as the rest of
/// the app, and it changes when the theme does.
/// ─────────────────────────────────────────────────────────────────────────
/// Standard OSM raster tiles. Keyless.
const String kMilerTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
/// Sent with every tile request. OSM's policy requires an identifiable agent —
/// an app that does not send one is the first thing they block.
const String kMilerTileAgent = 'com.doormile.partner';
/// ── The map surface ──
///
/// One widget for all four map screens, so tile source, attribution, gesture
/// rules and the "no rotation" decision are made once. A rider on a bike does
/// not want a map that spins because his thumb dragged crooked, and a rotated
/// map with north somewhere else is unreadable at a glance — which is the only
/// way this map is ever read.
class MilerMap extends StatelessWidget {
/// Optional: a map nobody drives — a static thumbnail in a sheet — needs no
/// controller, and creating one per build would leak a disposable.
final MapController? controller;
final LatLng initialCenter;
final double initialZoom;
final List<Polyline> polylines;
final List<Marker> markers;
final VoidCallback? onReady;
/// Extra layers drawn under the markers — accuracy circles, and the like.
final List<Widget> underlays;
const MilerMap({
super.key,
this.controller,
required this.initialCenter,
this.initialZoom = 14,
this.polylines = const [],
this.markers = const [],
this.underlays = const [],
this.onReady,
});
@override
Widget build(BuildContext context) {
return FlutterMap(
mapController: controller,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
minZoom: 3,
maxZoom: 18,
backgroundColor: ColorConstants.surfaceContainerLow,
interactionOptions: const InteractionOptions(
// Everything except rotation.
flags:
InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom |
InteractiveFlag.doubleTapZoom,
),
onMapReady: onReady,
),
children: [
TileLayer(
urlTemplate: kMilerTileUrl,
userAgentPackageName: kMilerTileAgent,
maxNativeZoom: 19,
// Keeps the last good tile on screen while the next zoom loads,
// instead of flashing the background through.
keepBuffer: 4,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
...underlays,
if (markers.isNotEmpty) MarkerLayer(markers: markers),
const _MilerAttribution(),
],
);
}
}
/// OSM's licence requires visible credit. Kept to a hairline chip in the
/// corner: it is a legal obligation, not a piece of the interface, and the
/// rider is here for the route.
class _MilerAttribution extends StatelessWidget {
const _MilerAttribution();
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomLeft,
child: Padding(
padding: const EdgeInsets.only(left: 6, bottom: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
'© OpenStreetMap',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
);
}
}
// ── Camera ──────────────────────────────────────────────────────────────────
/// Animated camera moves for a [MapController].
///
/// `flutter_map`'s controller only teleports — `move` and `fitCamera` both take
/// effect on the next frame. The route screens were built around a camera that
/// eases: the line waits for the camera to land before it draws, and a cut
/// makes that choreography read as a glitch instead of as a flight. So the
/// tween lives here, over the controller's instant moves.
class MilerMapCamera {
final MapController controller;
final TickerProvider vsync;
AnimationController? _anim;
MilerMapCamera({required this.controller, required this.vsync});
void dispose() {
_anim?.dispose();
_anim = null;
}
/// Eases to the camera that frames [points], and completes when it lands.
Future<void> fit(
List<LatLng> points, {
EdgeInsets padding = const EdgeInsets.all(60),
Duration duration = const Duration(milliseconds: 650),
double maxZoom = 16.5,
}) async {
if (points.isEmpty) return;
final fit = CameraFit.coordinates(
coordinates: points,
padding: padding,
maxZoom: maxZoom,
).fit(controller.camera);
await moveTo(fit.center, fit.zoom, duration: duration);
}
/// Eases to a centre and zoom, and completes when it lands.
Future<void> moveTo(
LatLng target,
double zoom, {
Duration duration = const Duration(milliseconds: 650),
}) async {
_anim?.dispose();
final startCentre = controller.camera.center;
final startZoom = controller.camera.zoom;
if (duration == Duration.zero) {
controller.move(target, zoom);
return;
}
final anim = AnimationController(vsync: vsync, duration: duration);
_anim = anim;
final curve = CurvedAnimation(parent: anim, curve: Curves.easeInOutCubic);
final completer = Completer<void>();
void tick() {
final t = curve.value;
controller.move(
LatLng(
startCentre.latitude + (target.latitude - startCentre.latitude) * t,
startCentre.longitude +
(target.longitude - startCentre.longitude) * t,
),
startZoom + (zoom - startZoom) * t,
);
}
anim.addListener(tick);
anim.forward().whenComplete(() {
if (!completer.isCompleted) completer.complete();
});
return completer.future;
}
}
// ── Pins ────────────────────────────────────────────────────────────────────
/// A map pin, as a widget.
///
/// The stock Google teardrop came in nine fixed hues, none of which is this
/// app's maroon, so a pickup and a delivery were told apart by "red-ish" and
/// "green-ish" and by nothing else. These take [StopKindUi.accent] directly, so
/// the pin on the map is the exact colour of the chip on the card the rider
/// tapped to get here.
class MilerPin extends StatelessWidget {
final Color color;
final IconData icon;
/// Draws the white ring and shadow larger, for the stop being worked.
final bool emphasised;
const MilerPin({
super.key,
required this.color,
required this.icon,
this.emphasised = false,
});
static const double size = 40;
static const double tail = 10;
@override
Widget build(BuildContext context) {
final head = emphasised ? size : size - 6;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: head,
height: head,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.28),
blurRadius: 8,
offset: const Offset(0, 3),
),
],
),
child: Icon(icon, size: head * 0.46, color: Colors.white),
),
// The stem that makes a circle read as "this exact point" rather than
// "somewhere under here".
CustomPaint(
size: const Size(12, tail),
painter: _PinTailPainter(color),
),
],
);
}
}
/// A numbered stop on a multi-stop route. Same shape as [MilerPin], carrying
/// the stop's position in the run instead of an icon — which is what the rider
/// is matching against the list below.
class MilerNumberPin extends StatelessWidget {
final int number;
final Color color;
final bool done;
const MilerNumberPin({
super.key,
required this.number,
required this.color,
this.done = false,
});
static const double size = 34;
static const double tail = 8;
@override
Widget build(BuildContext context) {
final fill = done ? ColorConstants.acceptGreen : color;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 7,
offset: const Offset(0, 3),
),
],
),
child: done
? const Icon(LucideIcons.check, size: 16, color: Colors.white)
: Text(
'$number',
style: const TextStyle(
fontSize: 14,
height: 1,
fontWeight: FontWeight.w700,
color: Colors.white,
fontFamily: FontConstants.fontFamily,
),
),
),
CustomPaint(size: const Size(10, tail), painter: _PinTailPainter(fill)),
],
);
}
}
/// The rider's own position: a dot, not a pin. A pin says "a thing is here";
/// a dot says "you are here", and every map app in the world agrees on it.
class MilerRiderDot extends StatelessWidget {
const MilerRiderDot({super.key});
static const double size = 22;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: ColorConstants.deliveryAccent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: ColorConstants.deliveryAccent.withValues(alpha: 0.45),
blurRadius: 10,
spreadRadius: 2,
),
],
),
);
}
}
class _PinTailPainter extends CustomPainter {
final Color color;
const _PinTailPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final path = Path()
..moveTo(0, 0)
..lineTo(size.width / 2, size.height)
..lineTo(size.width, 0)
..close();
canvas.drawPath(
path,
Paint()
..color = color
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(_PinTailPainter old) => old.color != color;
}
/// A marker anchored so its *point* sits on the coordinate, not its centre.
Marker milerMarker({
required LatLng point,
required Widget child,
required double width,
required double height,
}) {
return Marker(
point: point,
width: width,
height: height,
// The pin's tip is at the bottom edge of its box, so the box hangs above
// the coordinate. Google did this for us; flutter_map centres by default,
// which puts the tip half a pin south of the address.
alignment: Alignment.topCenter,
child: child,
);
}
/// Metres between two coordinates, on the great circle.
///
/// `Geolocator.distanceBetween` does this too, but it is a platform call: it
/// cannot run in a test, and the route screens ask for it once per point of a
/// several-hundred-point line.
double milerDistanceMetres(LatLng a, LatLng b) {
const earth = 6371000.0;
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));
}
double _rad(double deg) => deg * math.pi / 180.0;
/// ─────────────────────────────────────────────────────────────────────────
/// ONE LEG, DRAWN HONESTLY
///
/// The small orientation maps on the stop detail and pickup preview sheets
/// each hand-rolled the same picture and both told the same two lies:
///
/// • **A straight line pretending to be the ride.** The polyline was just
/// `[rider, stop]`, drawn solid — geometry no road follows, in the same
/// stroke the real map screen uses for an actual route.
/// • **A journey with one end.** The camera parked on the destination at a
/// fixed zoom, so a rider 4 km out watched the line exit the frame into
/// nothing — a pin, a stroke, and no second point anywhere on screen.
///
/// This widget is that picture done once, properly. It asks OSRM for the road
/// (same [MilerRouter] the map screen trusts), fits the camera to BOTH ends
/// with room to breathe, and always draws both endpoints — the destination
/// pin and the rider's dot. While the route is in flight, or when the router
/// is unreachable, the leg shows as a DASHED direct line: dashes are the map
/// vocabulary for "as the crow flies", so the fallback stops impersonating a
/// road. Non-interactive by design — orientation, not navigation.
/// ─────────────────────────────────────────────────────────────────────────
class MilerLegMap extends StatefulWidget {
/// The destination — the one end a leg always has.
final LatLng to;
/// Where the rider is. Null draws the destination alone, centred.
final LatLng? from;
/// The destination pin's accent and glyph — pass the stop's own.
final Color accent;
final IconData icon;
const MilerLegMap({
super.key,
required this.to,
required this.accent,
required this.icon,
this.from,
});
@override
State<MilerLegMap> createState() => _MilerLegMapState();
}
class _MilerLegMapState extends State<MilerLegMap> {
final MapController _controller = MapController();
List<LatLng> _road = const [];
bool _ready = false;
@override
void initState() {
super.initState();
_fetchRoad();
}
@override
void didUpdateWidget(covariant MilerLegMap old) {
super.didUpdateWidget(old);
if (old.from != widget.from || old.to != widget.to) {
_road = const [];
_fetchRoad();
_fit();
}
}
Future<void> _fetchRoad() async {
final from = widget.from;
if (from == null) return;
final road = await MilerRouter.route(from, widget.to);
if (!mounted || road.isEmpty) return;
setState(() => _road = road);
// The road can bulge outside the straight-line bounds; refit around it.
_fit();
}
void _fit() {
if (!_ready) return;
final from = widget.from;
if (from == null) return;
final points = _road.isNotEmpty ? _road : [from, widget.to];
_controller.fitCamera(
CameraFit.coordinates(
coordinates: points,
padding: const EdgeInsets.fromLTRB(36, 44, 36, 30),
),
);
}
@override
Widget build(BuildContext context) {
final from = widget.from;
return IgnorePointer(
child: MilerMap(
controller: _controller,
initialCenter: from == null
? widget.to
: LatLng(
(widget.to.latitude + from.latitude) / 2,
(widget.to.longitude + from.longitude) / 2,
),
initialZoom: 13,
onReady: () {
_ready = true;
// After layout, when the viewport has a size to fit within.
WidgetsBinding.instance.addPostFrameCallback((_) => _fit());
},
polylines: [
if (from != null)
Polyline(
points: _road.isNotEmpty ? _road : [from, widget.to],
color: widget.accent,
strokeWidth: 4,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
// Dashed while it is a bearing, solid once it is a road.
pattern: _road.isNotEmpty
? const StrokePattern.solid()
: StrokePattern.dashed(segments: const [14, 10]),
),
],
markers: [
milerMarker(
point: widget.to,
width: MilerPin.size + 8,
height: MilerPin.size + MilerPin.tail,
child: MilerPin(
color: widget.accent,
icon: widget.icon,
emphasised: true,
),
),
if (from != null)
milerMarker(
point: from,
width: MilerRiderDot.size,
height: MilerRiderDot.size,
child: const MilerRiderDot(),
),
],
),
);
}
}