Files
doormile_milderapp/lib/views/Dashboard/pickups/multi_map.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

829 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
part of 'pickups.dart';
class _StepPoint {
final int step;
final LatLng position;
_StepPoint(this.step, this.position);
}
// -------------------------------------------------------------------------
// MULTI CUSTOMER MAP (Shows all pickup with real step numbers)
// -------------------------------------------------------------------------
class MultiCustomerMapScreen extends StatefulWidget {
final List<Map<String, dynamic>> pickup;
final Map<String, int> preservedStepNumbers;
const MultiCustomerMapScreen({
super.key,
required this.pickup,
this.preservedStepNumbers = const {},
});
@override
State<MultiCustomerMapScreen> createState() => _MultiCustomerMapScreenState();
}
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
with SingleTickerProviderStateMixin {
final MapController mapController = MapController();
late final MilerMapCamera _camera;
List<Marker> markers = <Marker>[];
/// The route as known, faint, underneath.
List<Polyline> casingLines = <Polyline>[];
/// The bright line drawing itself in over the casing. Named rather than kept
/// in a set keyed by 'route_progress': `flutter_map` draws a list in order, so
/// the order is the z-order and the id had nothing left to do.
Polyline? progressLine;
List<Polyline> get routeLayers => <Polyline>[
...casingLines,
if (progressLine != null) progressLine!,
];
/// Bumped whenever a route layer changes mid-animation, so only the map
/// is rebuilt rather than the whole screen.
final ValueNotifier<int> _mapRepaint = ValueNotifier<int>(0);
LatLng? currentLocation;
bool _isLoading = true;
bool _mapReady = false;
final Map<String, Map<String, dynamic>> _pickupMap = {};
/// Every routed point, hub to last stop, in riding order.
///
/// The segments are fetched one leg at a time, but the *animation* has to run
/// across the whole route — drawing each leg in turn would restart at every
/// stop and read as five separate lines rather than one journey.
final List<LatLng> _fullRoute = [];
late final AnimationController _routeAnim;
@override
void initState() {
super.initState();
// Long enough to read as a route being traced rather than a line appearing;
// short enough that the rider is not waiting on it to see his stops.
_camera = MilerMapCamera(controller: mapController, vsync: this);
_routeAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..addListener(_onRouteTick);
if (widget.pickup.isNotEmpty) {
final firstPickup = widget.pickup.first;
final dropLat = _parseD(
firstPickup['droplat'] ?? firstPickup['pickuplat'] ?? 0,
);
final dropLon = _parseD(
firstPickup['droplon'] ?? firstPickup['pickuplong'] ?? 0,
);
if (dropLat != 0 && dropLon != 0) {
currentLocation = LatLng(dropLat, dropLon);
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
} else {
currentLocation = const LatLng(11.018356, 77.012596);
}
_loadMapData();
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
int _getStepNumber(Map<String, dynamic> Booking) {
final dynamic raw = Booking['step'] ?? Booking['Step'];
final int step = raw == null
? 0
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
return step;
}
String _getOrderKey(Map<String, dynamic> Booking) {
final pickupId = (Booking['pickupid'] ?? '').toString();
final orderId = (Booking['orderid'] ?? '').toString();
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
}
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
final orderKey = _getOrderKey(Booking);
if (widget.preservedStepNumbers.containsKey(orderKey)) {
return widget.preservedStepNumbers[orderKey]!;
}
final currentStep = _getStepNumber(Booking);
return currentStep;
}
Future<void> _loadMapData() async {
try {
if (mounted) {
setState(() {
_isLoading = false;
});
}
_getCurrentLocation().then((_) {
if (mounted) {
_createPickupMarkers();
}
});
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error loading map data: $e');
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _getCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
return;
}
}
Position? position;
try {
position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
distanceFilter: 0,
timeLimit: Duration(seconds: 5),
),
);
} catch (e) {
debugPrint(
'[CUSTOMER_MAP] Timeout getting location, using last known: $e',
);
position = await Geolocator.getLastKnownPosition();
}
final safePosition = position;
if (safePosition != null && mounted) {
setState(() {
currentLocation = LatLng(
safePosition.latitude,
safePosition.longitude,
);
});
}
} catch (e) {
debugPrint('[CUSTOMER_MAP] Error getting location: $e');
try {
final lastPos = await Geolocator.getLastKnownPosition();
if (lastPos != null && mounted) {
setState(() {
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
});
}
} catch (_) {}
}
}
int? _getNextPickupStep() {
// Find the first non-skipped pickup (next pickup from user location)
for (int i = 0; i < widget.pickup.length; i++) {
final pickup = widget.pickup[i];
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
if (status != 'skipped') {
final stepNumber = _getPreservedOrCurrentStep(pickup);
if (stepNumber > 0) {
return stepNumber;
} else {
// Calculate display step for bookings without step
final ordersWithStepBefore = widget.pickup
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.pickup
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
return totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
}
}
return null;
}
Future<void> _createPickupMarkers() async {
final tempMarkers = <Marker>[];
List<_StepPoint> stepPoints = [];
_pickupMap.clear();
final nextStep = _getNextPickupStep();
for (int i = 0; i < widget.pickup.length; i++) {
final pickup = widget.pickup[i];
final stepNumber = _getPreservedOrCurrentStep(pickup);
int displayStep;
if (stepNumber > 0) {
displayStep = stepNumber;
} else {
final ordersWithStepBefore = widget.pickup
.sublist(0, i)
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
final totalOrdersWithStep = widget.pickup
.where((o) => _getPreservedOrCurrentStep(o) > 0)
.length;
displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
}
final double lat = _parseD(pickup['droplat'] ?? pickup['pickuplat']);
final double lon = _parseD(pickup['droplon'] ?? pickup['pickuplong']);
if (lat == 0 || lon == 0) continue;
final orderId = (pickup['orderid'] ?? '').toString();
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
final isNext = displayStep == nextStep;
// Save pickup data for dialog
_pickupMap['pickup_$orderId'] = pickup;
// ── A widget, not a bitmap ──
//
// This used to await `_createCircularMarkerBitmap`, a hundred lines that
// opened a `PictureRecorder`, painted a circle and a number onto a canvas,
// rasterised it to PNG bytes and handed the bytes to the platform — per
// marker, per rebuild, asynchronously, because a Google Maps marker is a
// texture and cannot contain a `Text`. A `flutter_map` marker is a widget,
// so the number is just a number, in the app's own font.
//
// The tap that opened the customer sheet moves onto the widget too. The
// `InfoWindow` that carried the name is gone with it: it only appeared on
// tap, covered the route it was anchored to, and said what the sheet the
// same tap opens says properly.
tempMarkers.add(
milerMarker(
point: LatLng(lat, lon),
width: MilerNumberPin.size + 8,
height: MilerNumberPin.size + MilerNumberPin.tail,
child: GestureDetector(
onTap: () => _showCustomerDetailsSheet(pickup, displayStep),
child: MilerNumberPin(
number: displayStep,
color: isSkipped
? ColorConstants.warning
: isNext
? ColorConstants.primary
: ColorConstants.secondaryText,
),
),
),
);
stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon)));
}
if (currentLocation != null) {
tempMarkers.add(
milerMarker(
point: currentLocation!,
width: MilerRiderDot.size,
height: MilerRiderDot.size,
child: const MilerRiderDot(),
),
);
stepPoints.insert(0, _StepPoint(0, currentLocation!));
}
stepPoints.sort((a, b) => a.step.compareTo(b.step));
final newPolylines = <Polyline>[];
_fullRoute.clear();
for (int i = 0; i < stepPoints.length - 1; i++) {
final start = stepPoints[i].position;
final end = stepPoints[i + 1].position;
// OSRM instead of Directions — same answer, no key. See [MilerRouter].
final routePoints = await MilerRouter.route(start, end);
if (routePoints.isNotEmpty) {
_fullRoute.addAll(routePoints);
newPolylines.add(
Polyline(
points: routePoints,
strokeWidth: 6,
// The brand accent, matching the single-stop map. This was
// `Colors.blue`, so the two map screens drew the same route in two
// different colours.
color: ColorConstants.primary,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
),
);
} else {
// A leg the router could not resolve still has to enter the animated
// path, or the trace stops dead at the first unroutable stop.
_fullRoute.addAll([start, end]);
newPolylines.add(
Polyline(
points: [start, end],
strokeWidth: 4,
// Straight-line fallback — lighter, because it is a guess at the
// route rather than the route.
color: ColorConstants.primary.withValues(alpha: 0.35),
pattern: StrokePattern.dashed(segments: const [20, 12]),
),
);
}
}
if (mounted) {
setState(() {
markers = tempMarkers;
// The resolved segments become the faint casing — the route as known —
// and the bright line is then traced over them. Same two-layer treatment
// the single-stop map uses, so the two screens animate identically.
casingLines = [
for (final p in newPolylines)
Polyline(
points: p.points,
color: ColorConstants.primary.withValues(alpha: 0.18),
strokeWidth: 9,
pattern: p.pattern,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
),
];
});
if (_fullRoute.length > 1) {
_buildDistanceTable();
_routeAnim.forward(from: 0);
}
}
await Future.delayed(const Duration(milliseconds: 200));
_fitBoundsToMarkersAndPolylines();
}
/// Distance from the route's start to each point in [_fullRoute].
///
/// The animation walks this table rather than the point list, and that is the
/// difference between the head moving and the head stuttering. A routing API
/// puts vertices where the road bends, so a roundabout can carry twenty of
/// them across thirty metres while a bypass carries two across a kilometre.
/// Stepping by index spends equal time on each, which makes the head crawl
/// through every junction and leap down every straight.
List<double> _cumulative = <double>[];
void _buildDistanceTable() {
_cumulative = List<double>.filled(_fullRoute.length, 0);
if (_fullRoute.length < 2) return;
// Longitude degrees shrink with latitude, so scale them before measuring or
// an east–west leg is over-weighted. Plane geometry is ample here: the table
// only has to get the *ratios* between legs right.
final meanLat =
_fullRoute.map((p) => p.latitude).reduce((a, b) => a + b) /
_fullRoute.length;
final lonScale = math.cos(meanLat * math.pi / 180).abs().clamp(0.05, 1.0);
for (var i = 1; i < _fullRoute.length; i++) {
final a = _fullRoute[i - 1], b = _fullRoute[i];
final dy = b.latitude - a.latitude;
final dx = (b.longitude - a.longitude) * lonScale;
_cumulative[i] = _cumulative[i - 1] + math.sqrt(dy * dy + dx * dx);
}
}
/// The point [f] of the way along the route *by length*, as a segment index
/// plus a fraction into that segment.
(int, double) _atDistance(double f) {
final total = _cumulative.isEmpty ? 0.0 : _cumulative.last;
if (total <= 0) return (0, 0);
final target = (f.clamp(0.0, 1.0)) * total;
var lo = 0, hi = _cumulative.length - 1;
while (lo < hi) {
final mid = (lo + hi) >> 1;
if (_cumulative[mid] <= target) {
lo = mid + 1;
} else {
hi = mid;
}
}
final i = (lo - 1).clamp(0, _fullRoute.length - 2);
final span = _cumulative[i + 1] - _cumulative[i];
return (
i,
span <= 0 ? 0.0 : ((target - _cumulative[i]) / span).clamp(0.0, 1.0),
);
}
/// Reveals the bright line progressively, with an interpolated leading tip.
void _onRouteTick() {
if (!mounted || _fullRoute.length < 2) return;
if (_cumulative.length != _fullRoute.length) _buildDistanceTable();
final t = Curves.easeInOutCubic.transform(_routeAnim.value);
final (idx, frac) = _atDistance(t);
final shown = <LatLng>[..._fullRoute.sublist(0, idx + 1)];
if (idx < _fullRoute.length - 1) {
final a = _fullRoute[idx];
final b = _fullRoute[idx + 1];
shown.add(
LatLng(
a.latitude + (b.latitude - a.latitude) * frac,
a.longitude + (b.longitude - a.longitude) * frac,
),
);
}
progressLine = Polyline(
points: shown,
color: ColorConstants.primary,
strokeWidth: 6,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
// Only the map rebuilds — a `setState` here redrew the whole screen, sheet
// and all, sixty times a second to move one line.
_mapRepaint.value++;
}
/// Frames every stop and every metre of route.
///
/// The retry-with-delay dance this replaces existed because
/// `GoogleMapController.animateCamera` threw until the platform view had
/// finished creating itself. `flutter_map`'s camera is available as soon as
/// the map is in the tree.
Future<void> _fitBoundsToMarkersAndPolylines() async {
if (!mounted) return;
final points = <LatLng>[
for (final m in markers) m.point,
for (final poly in routeLayers) ...poly.points,
];
if (points.isEmpty) return;
await _camera.fit(points, padding: const EdgeInsets.all(64));
}
void _showCustomerDetailsSheet(Map<String, dynamic> pickup, int stepNumber) {
// ── Through the kit ──
//
// The furthest-drifted sheet in the app, and every difference was a
// decision re-made rather than inherited:
//
// • its own `RoundedRectangleBorder(20)` — a radius nothing else uses;
// • **no glass at all**, so it arrived opaque white over a live map while
// every other sheet is frosted;
// • `SafeArea(bottom: true)` **and** `Padding(bottom: viewInsets.bottom)`
// — the two summed, which is the double-inset bug written out in full:
// with the keyboard up the content floats a navigation bar's height
// above it;
// • a 24 gutter against the app's 20;
// • a 22sp/w600 title in **brand red**, where the system's header is
// 18/w700 slate — the accent belongs on the badge, not on the sentence;
// • and a close ✕, which the handle and the barrier already do.
showMilerSheet<void>(
context,
builder: (BuildContext context) {
final customerName = (pickup['pickupcustomer'] ?? 'Customer')
.toString();
final address = (pickup['pickupaddress'] ?? 'Address not available')
.toString();
final phone = (pickup['pickupcontactno'] ?? '').toString();
final orderId = (pickup['orderid'] ?? '').toString();
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
.trim();
final isSkipped = status == 'skipped';
return MilerSheetScaffold(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const MilerSheetHeader(
title: 'Customer details',
icon: LucideIcons.mapPinned,
),
SizedBox(height: 4.h),
// Step number and status
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: isSkipped
? ColorConstants.warning
: ColorConstants.primary,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
'Step $stepNumber',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14.sp,
),
),
),
if (isSkipped) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: ColorConstants.warning,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
'SKIPPED',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12.sp,
),
),
),
],
],
),
const SizedBox(height: 16),
// Customer name
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.user,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Customer Name',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
customerName,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Pickup address
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.mapPin,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipient Address',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
address,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// Phone number
if (phone.isNotEmpty)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.phone,
size: 20,
color: ColorConstants.secondaryText,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Contact Number',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
phone,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
),
),
],
),
),
],
),
const SizedBox(height: 20),
// Booking ID
Text(
'Booking ID: $orderId',
style: TextStyle(
fontFamily: FontConstants.fontFamily,
fontSize: 16.sp,
color: ColorConstants.slateText,
fontStyle: FontStyle.italic,
),
),
const SizedBox(height: 20),
// Call button
MilerButton(
label: 'Call',
icon: LucideIcons.phone,
color: ColorConstants.acceptGreen,
onPressed: () async {
// Close bottom sheet first
Navigator.of(context).pop();
// Small delay to ensure bottom sheet is closed
await Future.delayed(const Duration(milliseconds: 300));
// Then make the call
final bool success = await launchPhoneDialer(phone);
if (!success && context.mounted) {
AppFeedback.error(context, 'Could not open the dialer');
}
},
),
const SizedBox(height: 8),
],
),
),
);
},
);
}
@override
void dispose() {
// The class had no dispose before the route animation was added — a ticker
// outliving its State keeps the whole subtree alive and fires into a widget
// that is no longer mounted.
_routeAnim.dispose();
_mapRepaint.dispose();
_camera.dispose();
mapController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
LatLng initialTarget;
double initialZoom = 13;
if (currentLocation != null) {
initialTarget = currentLocation!;
} else if (widget.pickup.isNotEmpty) {
final firstPickup = widget.pickup.first;
final lat = _parseD(firstPickup['droplat'] ?? firstPickup['pickuplat']);
final lon = _parseD(firstPickup['droplon'] ?? firstPickup['pickuplong']);
initialTarget = (lat != 0 && lon != 0)
? LatLng(lat, lon)
: const LatLng(11.0168, 76.9558);
} else {
initialTarget = const LatLng(11.0168, 76.9558);
}
return Scaffold(
// ── A light app bar, not a red slab ──
//
// This was a solid brand-red bar with white text, 80pt tall, built out of
// a PreferredSize wrapping an AppBar whose `leading` was an infinitely
// wide Row holding both the back arrow and the title. Two problems: a
// saturated red band across the top of a map competes with the route drawn
// on it — the map is the content, and the chrome was louder than it — and
// every other pushed screen in the app has a white bar, so this one read
// as a different app.
//
// Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on
// the title where it costs nothing, a real `leading` slot, and the same
// hairline the rest of the app uses to close a bar.
// ── The sixth app-bar implementation, folded into the one ──
//
// This was a hand-rolled `AppBar`: a frosted wash, a maroon 19sp title
// and its own back button — so the map page's heading was a different
// colour and a different size from every other pushed screen. The audit
// note on [milerPageBar] says that bar is the only one a pushed screen
// gets; this one had simply never been converted.
//
// Now it inherits the brand ground, the white title and the shared back
// control along with everything else, and cannot drift again.
appBar: milerPageBar('Route map', onBack: () => Navigator.pop(context)),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: AfterEntrance(
placeholder: const MapPlaceholder(),
builder: (context) => ValueListenableBuilder<int>(
valueListenable: _mapRepaint,
builder: (context, _, _) => MilerMap(
controller: mapController,
initialCenter: initialTarget,
initialZoom: initialZoom,
polylines: routeLayers,
markers: markers,
onReady: () {
_mapReady = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_mapReady) _fitBoundsToMarkersAndPolylines();
});
},
),
),
),
);
}
}