miler map
This commit is contained in:
@@ -9,6 +9,7 @@ import 'package:get/get.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/views/helpers/constants/narrative.dart';
|
||||
import 'package:miler/views/helpers/constants/miler_type.dart';
|
||||
import 'package:miler/views/helpers/widgets/miler_app_bar.dart'
|
||||
show milerBrandSurface;
|
||||
@@ -64,10 +65,6 @@ class MilerBand extends StatelessWidget {
|
||||
/// translucent wash is the wrong tool for a surface.
|
||||
final Color? color;
|
||||
|
||||
/// An accent stripe down the leading edge, for a band whose state has to
|
||||
/// read without being examined. Null draws no stripe and no inset.
|
||||
final Color? rail;
|
||||
|
||||
/// Overrides the standard inset. Pass only where the child owns its own
|
||||
/// horizontal padding — a row that has to bleed to the band's edge.
|
||||
final EdgeInsets? padding;
|
||||
@@ -76,21 +73,36 @@ class MilerBand extends StatelessWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.color,
|
||||
this.rail,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
/// The inset a band keeps around its content.
|
||||
///
|
||||
/// One number for the whole app. With the bands running edge to edge, their
|
||||
/// padding is the only thing left aligning the page's left margin — two
|
||||
/// bands a couple of points apart is exactly how a column of text stops
|
||||
/// looking like a column.
|
||||
static double get pad => 20.w;
|
||||
/// One number for the whole app: two bands a couple of points apart is
|
||||
/// exactly how a column of text stops looking like a column.
|
||||
static double get pad => 18.w;
|
||||
|
||||
/// The gap of page ground between one band and the next. This is the only
|
||||
/// separator a band has, so it is a real gap rather than a hairline.
|
||||
static double get gap => 10.h;
|
||||
/// The page margin the band sits in.
|
||||
///
|
||||
/// ── Bands used to run edge to edge, and the page paid for it ──
|
||||
///
|
||||
/// A full-bleed band has no shape of its own, so the only thing separating
|
||||
/// one from the next was a [gap] of bare page showing through — which turned
|
||||
/// the ground into a set of stripes between the content. On the old slate
|
||||
/// canvas those stripes were dark grey bars, and the screen read as a stack
|
||||
/// of unrelated slabs rather than as a form.
|
||||
///
|
||||
/// A band is a card now: inset from both edges, rounded, on a hairline and a
|
||||
/// soft lift. The gap between two of them is air rather than a bar, and the
|
||||
/// page's left margin is the card's edge instead of the padding inside it.
|
||||
static double get gutter => 14.w;
|
||||
|
||||
/// The air between one band and the next.
|
||||
static double get gap => 12.h;
|
||||
|
||||
/// The corner. Sections of a form, so a little tighter than a page-level
|
||||
/// panel — see [Narrative.radiusCard], which is the shape Home's cards take.
|
||||
static double get radius => Narrative.radiusInner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -102,14 +114,16 @@ class MilerBand extends StatelessWidget {
|
||||
duration: DesignConstants.motionState,
|
||||
curve: Curves.easeOut,
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.symmetric(horizontal: gutter),
|
||||
padding: padding ?? EdgeInsets.symmetric(horizontal: pad, vertical: 16.h),
|
||||
decoration: BoxDecoration(
|
||||
color: color ?? ColorConstants.pureSurface,
|
||||
border: rail == null
|
||||
? null
|
||||
: Border(
|
||||
left: BorderSide(color: rail!, width: 3.w),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
// The hairline and the lift together, the same recipe every other card
|
||||
// in the app is built on — so a form section and a panel on Home read
|
||||
// as the same kind of object. See [Narrative.cardBox].
|
||||
border: Border.all(color: Narrative.cardBorder, width: 1),
|
||||
boxShadow: Narrative.cardShadow,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
@@ -261,7 +275,7 @@ class PrimaryButton extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -324,7 +338,7 @@ class SecondaryButton extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: c,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -370,7 +384,7 @@ class StatusChip extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -440,7 +454,7 @@ class SectionHeader extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
color: ColorConstants.onSurface,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -456,7 +470,7 @@ class SectionHeader extends StatelessWidget {
|
||||
action!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.primary,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -540,7 +554,7 @@ class InfoBanner extends StatelessWidget {
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
@@ -722,7 +736,7 @@ class AppLoader extends StatelessWidget {
|
||||
label!,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.onSurfaceVariant,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -1629,7 +1643,7 @@ class MilerButton extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
color: fg,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -2042,7 +2056,7 @@ class StopSourceLine extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
color: accent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -2063,7 +2077,7 @@ class StopSourceLine extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
// Tabular, because these are read as codes and a column of
|
||||
// them down a list should line up.
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
@@ -2124,7 +2138,7 @@ class OrderStatusTag extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.1,
|
||||
color: color,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
@@ -143,7 +143,7 @@ class _Message extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -156,7 +156,7 @@ class _Message extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
height: 1.45,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -172,7 +172,7 @@ class _Message extends StatelessWidget {
|
||||
'Try again',
|
||||
style: TextStyle(
|
||||
fontSize: 14.5.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -319,7 +319,7 @@ class MilerAvatar extends StatelessWidget {
|
||||
// Fixed .sp off a fixed box: the circle does not grow with
|
||||
// the system font scale, so neither can the letter inside it.
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
color: onBrand
|
||||
? ColorConstants.primary
|
||||
|
||||
@@ -149,7 +149,7 @@ class _MilerAttribution extends StatelessWidget {
|
||||
'© OpenStreetMap',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -345,7 +345,7 @@ class MilerNumberPin extends StatelessWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
),
|
||||
@@ -512,14 +512,102 @@ class _MilerLegMapState extends State<MilerLegMap> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<void>? _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<void> _wait(Duration d) {
|
||||
final completer = Completer<void>();
|
||||
_retryWait = completer;
|
||||
_retryTimer = Timer(d, () {
|
||||
_retryTimer = null;
|
||||
if (identical(_retryWait, completer)) _retryWait = null;
|
||||
if (!completer.isCompleted) completer.complete();
|
||||
});
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
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();
|
||||
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() {
|
||||
|
||||
@@ -209,7 +209,7 @@ class MilerSheetHeader extends StatelessWidget {
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.45,
|
||||
height: 1.2,
|
||||
color: ColorConstants.slateText,
|
||||
@@ -222,7 +222,7 @@ class MilerSheetHeader extends StatelessWidget {
|
||||
subtitle!,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
color: ColorConstants.secondaryText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
@@ -337,8 +337,8 @@ class MilerSheetChoiceRow extends StatelessWidget {
|
||||
color: selected ? accent : ColorConstants.slateText,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
fontWeight: selected
|
||||
? FontWeight.w700
|
||||
: FontWeight.w500,
|
||||
? FontWeight.w800
|
||||
: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -295,7 +295,7 @@ class _MilerSlideActionState extends State<MilerSlideAction>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -0.3,
|
||||
color: ColorConstants.onAccent,
|
||||
fontFamily: FontConstants.fontFamily,
|
||||
|
||||
Reference in New Issue
Block a user