UI-01: persistent 4-tab shell with a shared background map
Replaces the push/pop stack rooted at Record with a StatefulShellRoute (Map/Rides/Plan/Settings), each tab keeping its own navigator so Trip Detail and the route planner push within their own branch. AppShell/ShellScaffold host one shared RideMap instance behind every tab -- full opacity and interactive on Map, dimmed and non-interactive elsewhere -- so the camera position and live path survive a tab switch instead of being refetched. Every tab's AppBar/header is removed per the redesign's no-chrome mandate. Fixes a bug this surfaced: RideMap rendered a structurally different tree for empty vs. non-empty points, which crashed once the map became a long-lived shell background instead of a fresh per-screen widget. Unified the background-map tree shape so it no longer remounts mid-recording. Verified on Medium_Phone_API_35: all four tabs, live recording surviving tab switches with the camera preserved, and the real HOME-key lifecycle tile-drop from V3-04 still firing correctly in the shell context. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
This commit is contained in:
@@ -45,12 +45,24 @@ class RideMap extends StatefulWidget {
|
||||
this.height = 320,
|
||||
this.follow = false,
|
||||
this.tileProvider,
|
||||
this.fill = false,
|
||||
this.showEmptyLabel = true,
|
||||
});
|
||||
|
||||
final List<TrackPoint> points;
|
||||
final List<Segment> segments;
|
||||
final double height;
|
||||
|
||||
/// UI-01: fills whatever space the parent gives it (a `Positioned.fill`/`Expanded`
|
||||
/// ancestor) instead of the fixed [height] -- for the persistent full-screen
|
||||
/// background map behind every tab, where there is no card to size it.
|
||||
final bool fill;
|
||||
|
||||
/// False for the persistent background: an idle app with no ride yet is the normal
|
||||
/// state there, not an error to explain with text -- it should just look like a map,
|
||||
/// centred on a neutral default, with nothing overlaid.
|
||||
final bool showEmptyLabel;
|
||||
|
||||
/// V3-11: when supplied, tiles are read from (and written through to) the offline
|
||||
/// tile cache instead of flutter_map's own uncapped default. Null falls back to
|
||||
/// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests.
|
||||
@@ -118,13 +130,23 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
return grouped.values.toList();
|
||||
}
|
||||
|
||||
// `SizedBox.expand`, not `Positioned.fill` -- this widget is used both as a direct
|
||||
// `Stack` child (fine either way) and wrapped in an `IgnorePointer` first (the shared
|
||||
// shell background), where a `Positioned` is invalid because it isn't a direct child
|
||||
// of the `Stack`. `SizedBox.expand` fills the available space either way.
|
||||
Widget _sized({required Widget child}) =>
|
||||
widget.fill ? SizedBox.expand(child: child) : SizedBox(height: widget.height, child: child);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final hasPoints = widget.points.isNotEmpty;
|
||||
|
||||
if (widget.points.isEmpty) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
// The `showEmptyLabel && !hasPoints` case (a finished ride card with no points, e.g.
|
||||
// a corrupt/empty trip) renders text only, no map underneath -- that usage never
|
||||
// transitions live, so there's no risk to the element tree here.
|
||||
if (!hasPoints && widget.showEmptyLabel) {
|
||||
return _sized(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'No path recorded',
|
||||
@@ -134,69 +156,86 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
||||
);
|
||||
}
|
||||
|
||||
final all = [
|
||||
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
|
||||
];
|
||||
final bounds = geo.bounds(all)!;
|
||||
final polylines = _buildPolylines(colors);
|
||||
// UI-01 bug, found by a widget test: the empty and non-empty states used to return
|
||||
// structurally different widget trees (a bare `FlutterMap` versus
|
||||
// `ClipRRect > FlutterMap`) for the `showEmptyLabel: false` background-map usage.
|
||||
// The moment a live ride's first point arrived, Flutter treated that as a full
|
||||
// element swap rather than a rebuild of the same element -- unmounting the old
|
||||
// `FlutterMap` out from under a `MapController` that a `didUpdateWidget`-scheduled
|
||||
// post-frame callback was about to use, which threw "Looking up a deactivated
|
||||
// widget's ancestor is unsafe." Building exactly one shape always for this usage,
|
||||
// varying only the `MapOptions`/children by `hasPoints`, removes the swap entirely.
|
||||
geo.Bounds? bounds;
|
||||
if (hasPoints) {
|
||||
bounds = geo.bounds([
|
||||
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
|
||||
]);
|
||||
}
|
||||
final polylines = hasPoints ? _buildPolylines(colors) : const <Polyline>[];
|
||||
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
final map = ClipRRect(
|
||||
// The map draws to the edge of its box; clipping keeps it from painting over
|
||||
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: FlutterMap(
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCameraFit: bounds.isDegenerate
|
||||
// Every point at one spot — a parked "ride". Fitting this would zoom to
|
||||
// infinity, so centre and use a sane street-level zoom instead.
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: LatLngBounds(
|
||||
ll.LatLng(bounds.minLat, bounds.minLon),
|
||||
ll.LatLng(bounds.maxLat, bounds.maxLon),
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
// The clamp. Without it a short ride lands past OSM's max tile zoom
|
||||
// and renders an empty grid.
|
||||
maxZoom: maxTileZoom,
|
||||
borderRadius: widget.fill ? BorderRadius.zero : BorderRadius.circular(12),
|
||||
child: FlutterMap(
|
||||
mapController: _controller,
|
||||
options: MapOptions(
|
||||
initialCameraFit: (bounds == null || bounds.isDegenerate)
|
||||
// No points yet, or every point at one spot (a parked "ride") --
|
||||
// fitting a degenerate box would zoom to infinity, so centre on a
|
||||
// neutral or last-known point at a sane street-level zoom instead.
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: LatLngBounds(
|
||||
ll.LatLng(bounds.minLat, bounds.minLon),
|
||||
ll.LatLng(bounds.maxLat, bounds.maxLon),
|
||||
),
|
||||
initialCenter: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||
initialZoom: bounds.isDegenerate ? shortRideZoom : maxTileZoom,
|
||||
maxZoom: maxTileZoom,
|
||||
interactionOptions: const InteractionOptions(
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||
),
|
||||
onPositionChanged: !widget.follow
|
||||
? null
|
||||
: (position, hasGesture) {
|
||||
// Only a real pan/pinch turns following off -- the programmatic
|
||||
// moves this widget makes to chase the rider must not cancel
|
||||
// themselves out.
|
||||
if (hasGesture && _following) {
|
||||
setState(() => _following = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
// Omitted entirely while backgrounded -- not just visually hidden -- so no
|
||||
// tile request can fire off-screen. See the lifecycle observer above.
|
||||
if (!_backgrounded)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
|
||||
panBuffer: 0,
|
||||
tileProvider: widget.tileProvider,
|
||||
),
|
||||
PolylineLayer(polylines: polylines),
|
||||
],
|
||||
padding: const EdgeInsets.all(24),
|
||||
// The clamp. Without it a short ride lands past OSM's max tile zoom
|
||||
// and renders an empty grid.
|
||||
maxZoom: maxTileZoom,
|
||||
),
|
||||
initialCenter: bounds == null
|
||||
? const ll.LatLng(0, 0)
|
||||
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||
initialZoom: bounds == null
|
||||
? 2
|
||||
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||
maxZoom: maxTileZoom,
|
||||
interactionOptions: hasPoints
|
||||
? const InteractionOptions(
|
||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||
)
|
||||
: const InteractionOptions(flags: InteractiveFlag.none),
|
||||
onPositionChanged: !widget.follow
|
||||
? null
|
||||
: (position, hasGesture) {
|
||||
// Only a real pan/pinch turns following off -- the programmatic
|
||||
// moves this widget makes to chase the rider must not cancel
|
||||
// themselves out.
|
||||
if (hasGesture && _following) {
|
||||
setState(() => _following = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
// Omitted entirely while backgrounded -- not just visually hidden -- so no
|
||||
// tile request can fire off-screen. See the lifecycle observer above.
|
||||
if (!_backgrounded)
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: tileUserAgent,
|
||||
maxNativeZoom: maxTileZoom.toInt(),
|
||||
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
|
||||
panBuffer: 0,
|
||||
tileProvider: widget.tileProvider,
|
||||
),
|
||||
PolylineLayer(polylines: polylines),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return _sized(child: map);
|
||||
}
|
||||
|
||||
/// One polyline per speed run within each segment.
|
||||
|
||||
Reference in New Issue
Block a user