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:
2026-08-23 19:28:25 -05:00
parent 3e7878b6e1
commit 5ab4934a4d
10 changed files with 511 additions and 317 deletions

157
lib/src/ui/app_shell.dart Normal file
View 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',
),
],
);
}

View File

@@ -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.

View File

@@ -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({

View File

@@ -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.')),
);
}

View File

@@ -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'),

View File

@@ -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.

View File

@@ -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)