Files
rippr/lib/src/ui/app_shell.dart
uhryniuk fa6b1b8ef2 UI-05: rebuild Record screen as the Map HUD (design north star)
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
2026-08-24 08:22:51 -05:00

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