Replaces the scrolling stats-card layout with the full-bleed Map HUD: UI-04's draggable/resizable HUD widgets float over UI-01's shared background map (full opacity on this tab), and the old Pause/Stop/Resume/ Discard buttons become full-bleed, icon-only segments matching the mockup's tertiary-container/error-container colors exactly. The idle state keeps its prior compact layout deliberately -- HUD widgets only appear once a ride exists, preserving the existing "a resting screen must not look like a ride going nowhere" guarantee rather than reinterpreting it. State-machine logic (ticker, wakelock, speed subscription, error handling) is untouched; every pre-existing record-screen test passed unchanged against the rebuilt screen. Added tests that actually tap Start/Pause/Stop and verify engine state changes, confirm HUD widgets render over the map rather than replacing it, and verify the mounted-mode speed digit's real contrast ratio against GlassPanel's translucent surface specifically (the ticket's own named risk). Corrects a UI-04 mistake found while implementing this ticket: the HUD's default four metrics were ordered Speed/Distance/Elapsed/Max Speed, a guess made before reading the actual Map HUD mockup HTML closely. The real fixed row is Speed/Avg Speed/Dist/Time -- reordered HudMetric to match and updated every test that asserted the old order. Adds PulsingLocationMarker (UI-03) to RideMap's live usage via a new showLocationMarker flag, and explicit tertiaryContainer/errorContainer tokens to ripprColors so the control bar matches the design system's literal values rather than an auto-derived tonal palette. Verified end-to-end on a real emulator: Start, Pause, Resume, and Stop all correctly drive the trip state machine with the full live map behind everything. A lengthy false alarm during this verification (taps appearing to do nothing) turned out to be a screenshot-scale mis-measurement on the verification side, not an app defect -- resolved by sampling pixel colors directly from the raw screenshot to find the control bar's true on-screen position. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
162 lines
6.2 KiB
Dart
162 lines
6.2 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 '../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),
|
|
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',
|
|
),
|
|
],
|
|
);
|
|
}
|