448 lines
15 KiB
Dart
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;
|