import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.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 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(Icons.check_rounded, 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;