/// 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 'package:latlong2/latlong.dart' as ll; 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 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 [] : ref.watch(livePointsProvider(trip.id)).valueOrNull ?? const []; final segments = trip == null ? const [] : ref.watch(liveSegmentsProvider(trip.id)).valueOrNull ?? const []; // FB-01: only watched while idle, so there's never a duplicate GPS consumer during // an active recording -- the moment a trip starts, this stops being watched at all. final ambientFix = trip == null ? ref.watch(ambientPositionProvider).valueOrNull : null; final ambientPosition = ambientFix == null ? null : ll.LatLng(ambientFix.latitude, ambientFix.longitude); 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, ambientPosition: ambientPosition, follow: isMapTab, fill: true, showEmptyLabel: false, tileProvider: ref.watch(cachedTileProviderProvider), skeletonMode: ref.watch(mapConnectivityProvider).skeletonMode, // UI-05: only on the Map tab -- a pulsing marker ticking away behind // a scrim on every other tab would be wasted animation no one sees. showLocationMarker: isMapTab, ), ), ), // 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 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', ), ], ); }