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>
594 lines
20 KiB
Dart
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(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|