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',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user