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:
157
lib/src/ui/app_shell.dart
Normal file
157
lib/src/ui/app_shell.dart
Normal file
@@ -0,0 +1,157 @@
|
||||
/// UI-01: the persistent 4-tab shell. Replaces the old push/pop stack rooted at Record.
|
||||
///
|
||||
/// One background map, always mounted, shared by every tab -- not four separate map
|
||||
/// instances. Full opacity and interactive on the Map tab; dimmed and non-interactive
|
||||
/// (taps pass through to the tab's real content) everywhere else. This is what lets the
|
||||
/// map survive a tab switch with its camera position and live path intact, and what
|
||||
/// makes "the map is active on every tab" true without refetching tiles four times over.
|
||||
///
|
||||
/// No `AppBar` anywhere in this shell or in any of the four tab roots -- the point of the
|
||||
/// redesign is showing as much map as possible, and a persistent bottom nav plus zero
|
||||
/// header chrome is what buys that space back.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../app/providers.dart';
|
||||
import '../domain/models.dart';
|
||||
import 'components/ride_map.dart';
|
||||
|
||||
/// The thin go_router adapter. Kept separate from [ShellScaffold] so tests can drive the
|
||||
/// actual shell logic (background map, dimming, nav bar) with a plain `currentIndex` and
|
||||
/// `child`, without needing to construct a real [StatefulNavigationShell] -- which
|
||||
/// go_router only ever builds internally.
|
||||
class AppShell extends StatelessWidget {
|
||||
const AppShell({super.key, required this.navigationShell});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ShellScaffold(
|
||||
currentIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: (index) => navigationShell.goBranch(
|
||||
index,
|
||||
// Tapping the already-active tab pops it back to its own root, matching the
|
||||
// conventional "tap the active tab to go home" behaviour of a bottom-tab app.
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
child: navigationShell,
|
||||
);
|
||||
}
|
||||
|
||||
class ShellScaffold extends ConsumerWidget {
|
||||
const ShellScaffold({
|
||||
super.key,
|
||||
required this.currentIndex,
|
||||
required this.onDestinationSelected,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final Widget child;
|
||||
|
||||
static const mapTabIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isMapTab = currentIndex == mapTabIndex;
|
||||
// The Settings toggle's whole point (see its own doc comment) is that no tile is
|
||||
// ever fetched when off, not merely hidden -- that guarantee must survive this
|
||||
// screen becoming a permanent background across every tab, not just apply to the
|
||||
// Map tab the way it used to.
|
||||
final mapEnabled = ref.watch(mapEnabledProvider);
|
||||
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||
final points = trip == null
|
||||
? const <TrackPoint>[]
|
||||
: ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const <TrackPoint>[];
|
||||
final segments = trip == null
|
||||
? const <Segment>[]
|
||||
: ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const <Segment>[];
|
||||
|
||||
return Scaffold(
|
||||
// Falls back to the ordinary theme background when the map is disabled -- the
|
||||
// per-tab screens are transparent now, relying on this shell to paint something.
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
children: [
|
||||
// The one shared map instance. IgnorePointer rather than a per-tab rebuild:
|
||||
// keeping this the same widget across tab switches is what preserves camera
|
||||
// position and avoids refetching tiles. Not constructed at all when the map is
|
||||
// disabled -- no widget means no tile request can fire, the same real
|
||||
// short-circuit `mapEnabledProvider` always guaranteed.
|
||||
if (mapEnabled)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
ignoring: !isMapTab,
|
||||
child: RideMap(
|
||||
key: const Key('shell-background-map'),
|
||||
points: points,
|
||||
segments: segments,
|
||||
follow: isMapTab,
|
||||
fill: true,
|
||||
showEmptyLabel: false,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Dims the map behind every tab except Map itself -- decoration there, not a
|
||||
// control surface; the scrim also absorbs taps so they can't reach the map.
|
||||
if (mapEnabled && !isMapTab)
|
||||
const Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ColoredBox(color: Color(0xB3000000)),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: child),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: _ShellNavBar(
|
||||
currentIndex: currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShellNavBar extends StatelessWidget {
|
||||
const _ShellNavBar({required this.currentIndex, required this.onDestinationSelected});
|
||||
|
||||
final int currentIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => NavigationBar(
|
||||
key: const Key('shell-nav-bar'),
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
key: Key('nav-map'),
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Map',
|
||||
),
|
||||
NavigationDestination(
|
||||
key: Key('nav-rides'),
|
||||
icon: Icon(Icons.directions_bike_outlined),
|
||||
selectedIcon: Icon(Icons.directions_bike),
|
||||
label: 'Rides',
|
||||
),
|
||||
NavigationDestination(
|
||||
key: Key('nav-plan'),
|
||||
icon: Icon(Icons.route_outlined),
|
||||
selectedIcon: Icon(Icons.route),
|
||||
label: 'Plan',
|
||||
),
|
||||
NavigationDestination(
|
||||
key: Key('nav-settings'),
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../../domain/models.dart';
|
||||
import '../../recording/location_source.dart';
|
||||
import '../../recording/wakelock_controller.dart';
|
||||
import '../../telemetry/telemetry.dart';
|
||||
import '../components/ride_map.dart';
|
||||
import '../components/stats.dart';
|
||||
import '../format.dart';
|
||||
import '../theme.dart';
|
||||
@@ -38,16 +37,7 @@ class RecordUiState {
|
||||
}
|
||||
|
||||
class RecordScreen extends ConsumerStatefulWidget {
|
||||
const RecordScreen({
|
||||
super.key,
|
||||
this.onOpenTrips,
|
||||
this.onOpenSettings,
|
||||
this.onOpenRoutes,
|
||||
});
|
||||
|
||||
final VoidCallback? onOpenTrips;
|
||||
final VoidCallback? onOpenSettings;
|
||||
final VoidCallback? onOpenRoutes;
|
||||
const RecordScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RecordScreen> createState() => _RecordScreenState();
|
||||
@@ -158,7 +148,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
// colour scheme straight off a locally-built theme rather than `Theme.of(context)`,
|
||||
// which would still report the app-wide (dark) theme here.
|
||||
final theme = mountedMode ? ripprMountedTheme() : Theme.of(context);
|
||||
final colors = theme.colorScheme;
|
||||
final engine = ref.watch(recordingEngineProvider);
|
||||
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
@@ -173,7 +162,13 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
: (_now - trip.startedAt).clamp(0, 1 << 62),
|
||||
);
|
||||
|
||||
// UI-01: no header, no embedded map card -- the map is the shared background behind
|
||||
// this whole tab (`AppShell`), full opacity here. A quick mounted-mode toggle used to
|
||||
// live in a header row; that row is gone, and the toggle's only home for now is
|
||||
// Settings' existing switch (see the ticket's Outcome for why this is a deliberate,
|
||||
// temporary trade rather than an oversight -- UI-05 restyles this screen properly).
|
||||
return Theme(data: theme, child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
// Scrollable, but still centred when there is room.
|
||||
//
|
||||
// With a ride active the card grows to six stat rows, which overflows a short
|
||||
@@ -190,56 +185,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'RIPPR',
|
||||
style: TextStyle(
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 6,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('open-routes'),
|
||||
onPressed: widget.onOpenRoutes,
|
||||
child: const Text('Routes'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: widget.onOpenTrips,
|
||||
child: const Text('Rides'),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('mounted-mode-toggle'),
|
||||
icon: Icon(mountedMode
|
||||
? Icons.motorcycle
|
||||
: Icons.motorcycle_outlined),
|
||||
tooltip: mountedMode
|
||||
? 'Mounted mode on'
|
||||
: 'Mounted mode off',
|
||||
color: mountedMode ? colors.primary : null,
|
||||
onPressed: () async {
|
||||
final next = !mountedMode;
|
||||
await ref.read(configProvider)?.setMountedMode(next);
|
||||
ref.read(mountedModeProvider.notifier).state = next;
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('open-settings'),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
tooltip: 'Settings',
|
||||
onPressed: widget.onOpenSettings,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Current speed, not max: a max figure only moves when you beat it, which
|
||||
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
|
||||
// it must not regress.
|
||||
@@ -252,17 +197,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// V3-04: RecordingEngine has no idea this exists -- it is fed
|
||||
// entirely from a Drift stream, the same seam the trip-detail map
|
||||
// already uses. Off by default (mapEnabledProvider) until battery
|
||||
// impact is measured (V3-13), and only while a ride is actually
|
||||
// active -- an idle screen has nothing to draw and no reason to
|
||||
// hold a tile layer alive.
|
||||
if (ui.trip != null && ref.watch(mapEnabledProvider)) ...[
|
||||
_LiveMap(tripId: ui.trip!.id),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -352,28 +286,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin adapter from the two live Drift streams to [RideMap]. Kept out of
|
||||
/// [_RecordScreenState] so a provider read/write cycle here can't be confused with the
|
||||
/// engine's own state -- this widget only ever reads.
|
||||
class _LiveMap extends ConsumerWidget {
|
||||
const _LiveMap({required this.tripId});
|
||||
|
||||
final int tripId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final points = ref.watch(livePointsProvider(tripId)).valueOrNull ?? const [];
|
||||
final segments = ref.watch(liveSegmentsProvider(tripId)).valueOrNull ?? const [];
|
||||
return RideMap(
|
||||
key: const Key('live-map'),
|
||||
points: points,
|
||||
segments: segments,
|
||||
height: 220,
|
||||
follow: true,
|
||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Controls extends StatelessWidget {
|
||||
const _Controls({
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/// Ported from `com.rippr.ui.RipprNavHost`.
|
||||
///
|
||||
/// Three destinations, same shape as the Compose original. `go_router` replaces
|
||||
/// `navigation-compose`; the path parameter is parsed explicitly, mirroring the Kotlin
|
||||
/// comment about ids arriving as the wrong type when the argument type is left implicit.
|
||||
/// UI-01: a persistent 4-tab shell (`StatefulShellRoute`), replacing the push/pop stack
|
||||
/// rooted at Record that this app used through v3. Each branch keeps its own navigator,
|
||||
/// so pushing Trip Detail from Rides -- or the route planner from Plan -- doesn't
|
||||
/// disturb the other tabs' state or the active tab index.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'app_shell.dart';
|
||||
import 'detail/trip_detail_screen.dart';
|
||||
import 'record/record_screen.dart';
|
||||
import 'routes/route_planner_screen.dart';
|
||||
@@ -17,65 +17,81 @@ import 'trips/trips_screen.dart';
|
||||
|
||||
abstract final class Routes {
|
||||
static const record = '/';
|
||||
static const trips = '/trips';
|
||||
static const tripDetail = '/trip/:tripId';
|
||||
static const trips = '/rides';
|
||||
static const tripDetail = '/rides/:tripId';
|
||||
static const settings = '/settings';
|
||||
static const routes = '/routes';
|
||||
static const routePlanner = '/routes/:routeId';
|
||||
static const routes = '/plan';
|
||||
static const routePlanner = '/plan/:routeId';
|
||||
|
||||
static String detailFor(int tripId) => '/trip/$tripId';
|
||||
static String plannerFor(int routeId) => '/routes/$routeId';
|
||||
static String detailFor(int tripId) => '/rides/$tripId';
|
||||
static String plannerFor(int routeId) => '/plan/$routeId';
|
||||
}
|
||||
|
||||
GoRouter buildRouter() => GoRouter(
|
||||
initialLocation: Routes.record,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.record,
|
||||
builder: (context, state) => RecordScreen(
|
||||
onOpenTrips: () => context.push(Routes.trips),
|
||||
onOpenSettings: () => context.push(Routes.settings),
|
||||
onOpenRoutes: () => context.push(Routes.routes),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.routes,
|
||||
builder: (context, state) => RoutesListScreen(
|
||||
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.routePlanner,
|
||||
builder: (context, state) {
|
||||
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
|
||||
if (id == null) return const _NotFound();
|
||||
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) =>
|
||||
SettingsScreen(onBack: () => context.pop()),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.trips,
|
||||
builder: (context, state) => TripsScreen(
|
||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.tripDetail,
|
||||
builder: (context, state) {
|
||||
// Parsed explicitly. A malformed or missing id must land on a real screen
|
||||
// saying so, never on a blank one or a crash.
|
||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||
if (id == null) {
|
||||
return const _NotFound();
|
||||
}
|
||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||
},
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, navigationShell) =>
|
||||
AppShell(navigationShell: navigationShell),
|
||||
branches: [
|
||||
// Branch 0: Map. The Record screen, headerless -- see AppShell's doc comment.
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(path: Routes.record, builder: (context, state) => const RecordScreen()),
|
||||
],
|
||||
),
|
||||
// Branch 1: Rides. Trip Detail pushes within this branch's own navigator.
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.trips,
|
||||
builder: (context, state) => TripsScreen(
|
||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':tripId',
|
||||
builder: (context, state) {
|
||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||
if (id == null) return const _NotFound();
|
||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Branch 2: Plan. The route planner pushes within this branch's own navigator.
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.routes,
|
||||
builder: (context, state) => RoutesListScreen(
|
||||
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':routeId',
|
||||
builder: (context, state) {
|
||||
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
|
||||
if (id == null) return const _NotFound();
|
||||
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Branch 3: Settings.
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: Routes.settings,
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
errorBuilder: (context, state) => const _NotFound(),
|
||||
@@ -86,13 +102,11 @@ class _NotFound extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () =>
|
||||
context.canPop() ? context.pop() : context.go(Routes.record),
|
||||
body: Center(
|
||||
child: TextButton(
|
||||
onPressed: () => context.canPop() ? context.pop() : context.go(Routes.record),
|
||||
child: const Text('That ride could not be found. Back'),
|
||||
),
|
||||
),
|
||||
body: const Center(child: Text('That ride could not be found.')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,9 @@ import '../components/stats.dart';
|
||||
import '../format.dart';
|
||||
|
||||
class RoutesListScreen extends ConsumerWidget {
|
||||
const RoutesListScreen({super.key, this.onOpenRoute, this.onBack});
|
||||
const RoutesListScreen({super.key, this.onOpenRoute});
|
||||
|
||||
final void Function(int routeId)? onOpenRoute;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -21,7 +20,10 @@ class RoutesListScreen extends ConsumerWidget {
|
||||
final routes = ref.watch(routePlansProvider).valueOrNull ?? const <RoutePlan>[];
|
||||
final units = ref.watch(unitSystemProvider);
|
||||
|
||||
// UI-01: no back/title header -- Plan is a tab now. "New route" is functional, not
|
||||
// chrome, so it stays, right-aligned on its own.
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -30,15 +32,6 @@ class RoutesListScreen extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
key: const Key('back'),
|
||||
onPressed: onBack,
|
||||
child: const Text('‹ Record'),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8),
|
||||
child: Text('ROUTES', style: TextStyle(letterSpacing: 2)),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
key: const Key('new-route'),
|
||||
|
||||
@@ -13,23 +13,16 @@ import '../../config/config.dart';
|
||||
import '../../domain/models.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerWidget {
|
||||
const SettingsScreen({super.key, this.onBack});
|
||||
|
||||
final VoidCallback? onBack;
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final config = ref.watch(configProvider);
|
||||
|
||||
// UI-01: no AppBar -- Settings is a tab now, reached via the persistent bottom nav,
|
||||
// not a pushed screen with somewhere to back out to.
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
key: const Key('back'),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: onBack,
|
||||
),
|
||||
title: const Text('Settings'),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
// Config loads once at startup (see main.dart) and is normally already present by
|
||||
// the time anyone reaches this screen, but the seam is real: show a spinner rather
|
||||
// than crash on a null read in the small window before it resolves.
|
||||
|
||||
@@ -21,10 +21,9 @@ final _selectionProvider = StateProvider.autoDispose<Set<int>>(
|
||||
);
|
||||
|
||||
class TripsScreen extends ConsumerWidget {
|
||||
const TripsScreen({super.key, this.onOpenTrip, this.onBack});
|
||||
const TripsScreen({super.key, this.onOpenTrip});
|
||||
|
||||
final void Function(int tripId)? onOpenTrip;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -44,18 +43,22 @@ class TripsScreen extends ConsumerWidget {
|
||||
void clearSelection() =>
|
||||
ref.read(_selectionProvider.notifier).state = <int>{};
|
||||
|
||||
// UI-01: no back/title header -- Rides is a tab now. The selection toolbar (Cancel/
|
||||
// count/Merge/Delete) is functional, not chrome, so it stays -- it only appears
|
||||
// while `selecting` is true.
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
if (selecting) ...[
|
||||
if (selecting)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: clearSelection,
|
||||
child: const Text('Cancel'),
|
||||
@@ -67,27 +70,7 @@ class TripsScreen extends ConsumerWidget {
|
||||
style: TextStyle(fontSize: 14, color: colors.outline),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
TextButton(
|
||||
key: const Key('back'),
|
||||
onPressed: onBack,
|
||||
child: const Text('‹ Record'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
'RIDES',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 3,
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (selecting) ...[
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
key: const Key('merge'),
|
||||
onPressed: canMerge
|
||||
@@ -104,9 +87,8 @@ class TripsScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: switch (tripsAsync) {
|
||||
AsyncValue(hasValue: true, value: final list)
|
||||
|
||||
Reference in New Issue
Block a user