Files
2026-08-11 13:16:33 +05:30

448 lines
15 KiB
Dart

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<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.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<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(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;