Adds ambientPositionProvider (never calls LocationSource.stop(), only the idempotent start()) and wires it into ShellScaffold whenever no trip is active, so the persistent background map centers and follows the device's live GPS position at street-level zoom instead of sitting at (0,0)/zoom 2 until a recording starts. RideMap gains an ambientPosition param that the existing chase-camera/pan-cancel/location-marker logic falls back to whenever there are no recorded points, with recorded points always taking priority. Also brings in the docs/feedback ticket set (FB-01..FB-05, README, FEEDBACK.md) that this worktree's branch point predated. Adds 10 tests (374 -> 384): RideMap-level ambient centering/chase/pan-cancel/ marker coverage in test/ride_map_test.dart, plus shell-wiring and provider-level (never-calls-stop, mapEnabledProvider-off) coverage in test/widget_test.dart. flutter analyze remains clean.
171 lines
6.7 KiB
Dart
171 lines
6.7 KiB
Dart
/// 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<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>[];
|
|
// 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<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',
|
|
),
|
|
],
|
|
);
|
|
}
|