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.miler'; /// ── 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 polylines; final List markers; final VoidCallback? onReady; /// Extra layers drawn under the markers — accuracy circles, and the like. final List 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.w600, 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 fit( List 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 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 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.w800, 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 createState() => _MilerLegMapState(); } class _MilerLegMapState extends State { final MapController _controller = MapController(); List _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(); } } /// How many times to ask for the road before settling for the bearing. /// /// ── Why one attempt was never enough ── /// /// `route()` answers an empty list for every failure — a 429, a cold DNS /// lookup that outran the timeout, a tunnel — and this asked exactly once, /// from `initState`. The only other trigger is `didUpdateWidget`, and it /// fires on a change of *endpoint*: a rider standing at a door changes /// neither. So a single unlucky moment at the instant the sheet opened left /// `_road` empty for as long as the sheet stayed open, and the leg drew as a /// dashed straight line across a route the router would have returned a /// second later. That is the "it shows a straight line, not the real route" /// report, and it is a retry bug rather than a routing one — the public OSRM /// server answers this leg in under a second when asked again. /// /// Three tries with a widening gap. Cheap, bounded, and it costs nothing at /// all in the normal case where the first answer is the road. static const int _routeAttempts = 3; static const Duration _retryBackoff = Duration(milliseconds: 700); /// Distinguishes a stale in-flight request from the current one, so a leg /// that changed while the network was slow cannot have the previous leg's /// road painted onto it when the old request finally lands. int _fetchGeneration = 0; /// ── The backoff has to be cancellable, not just ignored ── /// /// A bare `Future.delayed` checks `mounted` on the far side of the wait, which /// is too late for the one observer that counts: the timer is still pending /// when the tree goes, and Flutter's test binding asserts on exactly that — /// *"A Timer is still pending even after the widget tree was disposed"*. It /// failed 15 tests that open this sheet and dispose it inside the retry gap. /// /// Off a test bench it is the same leak in slower motion: a rider flicking /// through six stops leaves six timers running against six dead widgets. /// /// So the wait is a [Timer] this state owns and [dispose] cancels — and /// disposing also completes the pending waiter, so the retry loop wakes, /// sees `!mounted`, and unwinds instead of parking forever on a future /// nothing will ever finish. Timer? _retryTimer; Completer? _retryWait; @override void dispose() { _retryTimer?.cancel(); _retryTimer = null; final waiter = _retryWait; _retryWait = null; if (waiter != null && !waiter.isCompleted) waiter.complete(); super.dispose(); } /// A cancellable [Future.delayed]. See [_retryTimer]. Future _wait(Duration d) { final completer = Completer(); _retryWait = completer; _retryTimer = Timer(d, () { _retryTimer = null; if (identical(_retryWait, completer)) _retryWait = null; if (!completer.isCompleted) completer.complete(); }); return completer.future; } Future _fetchRoad() async { final from = widget.from; if (from == null) return; final to = widget.to; final generation = ++_fetchGeneration; for (var attempt = 1; attempt <= _routeAttempts; attempt++) { final road = await MilerRouter.route(from, to); // Superseded, or gone from the tree, while the request was in flight. if (!mounted || generation != _fetchGeneration) return; if (road.isNotEmpty) { setState(() => _road = road); // The road can bulge outside the straight-line bounds; refit around it. _fit(); return; } if (attempt < _routeAttempts) { await _wait(_retryBackoff * attempt); if (!mounted || generation != _fetchGeneration) return; } } // Says which leg gave up, so a rider reporting a dashed line can be matched // against the `[MAP] OSRM …` line that explains why. debugPrint( '[MAP] no road after $_routeAttempts attempts for ' '${from.latitude},${from.longitude} -> ${to.latitude},${to.longitude} — ' 'drawing the dashed bearing instead.', ); } 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(), ), ], ), ); } }