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:
@@ -19,7 +19,7 @@ v3 held to.
|
|||||||
|
|
||||||
| # | Ticket | Size | Depends on | Status |
|
| # | Ticket | Size | Depends on | Status |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Not started |
|
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done |
|
||||||
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Not started |
|
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Not started |
|
||||||
| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started |
|
| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Not started |
|
||||||
| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started |
|
| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Not started |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# UI-01 — Persistent tab shell with an always-visible background map
|
# UI-01 — Persistent tab shell with an always-visible background map
|
||||||
|
|
||||||
**Depends on** nothing · **Size** L · **Status** Not started
|
**Depends on** nothing · **Size** L · **Status** Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map,
|
Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map,
|
||||||
@@ -98,3 +98,85 @@ from a persistent bar.
|
|||||||
The skeleton/offline map state (UI-02). Any visual redesign of what's inside a tab
|
The skeleton/offline map state (UI-02). Any visual redesign of what's inside a tab
|
||||||
(UI-04/05/06) — this ticket only has to make the shell and shared background map exist,
|
(UI-04/05/06) — this ticket only has to make the shell and shared background map exist,
|
||||||
correctly, with today's screen content still working inside it.
|
correctly, with today's screen content still working inside it.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Shipped as designed: `lib/src/ui/router.dart` now builds a single `StatefulShellRoute
|
||||||
|
.indexedStack` with four branches (Map `/`, Rides `/rides`, Plan `/plan`, Settings
|
||||||
|
`/settings`), each with Trip Detail / the route planner nested as a child `GoRoute`
|
||||||
|
inside its own branch so pushing/popping there never disturbs the other tabs or the
|
||||||
|
active tab index. `lib/src/ui/app_shell.dart` is new: `AppShell` is a thin go_router
|
||||||
|
adapter around `ShellScaffold`, a plain-parameter (`currentIndex`/`onDestinationSelected`
|
||||||
|
/`child`) `ConsumerWidget` that owns the one shared `RideMap` instance, the dimming scrim,
|
||||||
|
and the bottom `NavigationBar`. Splitting the two was necessary for testability —
|
||||||
|
go_router only ever constructs a real `StatefulNavigationShell` itself, so widget tests
|
||||||
|
drive `ShellScaffold` directly with a plain `int` instead.
|
||||||
|
|
||||||
|
Every top-level tab screen (`RecordScreen`, `TripsScreen`, `RoutesListScreen`,
|
||||||
|
`SettingsScreen`) had its `AppBar`/back-button header removed and its `Scaffold`
|
||||||
|
background set to transparent so the shared map shows through. `RecordScreen` also lost
|
||||||
|
its per-screen `onOpenTrips`/`onOpenSettings`/`onOpenRoutes` navigation callbacks and its
|
||||||
|
embedded `_LiveMap` card entirely — navigation is the shell's nav bar now, and the map is
|
||||||
|
the shell's permanent background rather than something each screen constructs for
|
||||||
|
itself. One deliberate temporary trade: the mounted-mode toggle's only remaining button
|
||||||
|
was on `RecordScreen`'s removed header; its sole access point until UI-05 restyles the
|
||||||
|
Record screen is now Settings' existing `mounted-mode-switch`.
|
||||||
|
|
||||||
|
**Bug found and fixed during this ticket, not anticipated by the plan:** `RideMap`
|
||||||
|
returned a structurally different widget tree for empty vs. non-empty points in its
|
||||||
|
`showEmptyLabel: false` (background) mode — a bare `FlutterMap` vs. `ClipRRect >
|
||||||
|
FlutterMap`. Once the map became a long-lived shell background instead of a
|
||||||
|
freshly-mounted per-screen widget, this became visible: the moment a live ride's first
|
||||||
|
point arrived, Flutter unmounted/remounted the differing subtree mid-flight, and
|
||||||
|
`RideMap`'s own `didUpdateWidget`-scheduled `_controller.camera` post-frame callback fired
|
||||||
|
against the stale element, throwing "Looking up a deactivated widget's ancestor is
|
||||||
|
unsafe." Fixed by unifying the `showEmptyLabel: false` and non-empty branches into one
|
||||||
|
tree shape (`ClipRRect > FlutterMap` always, `MapOptions`/children varying only by
|
||||||
|
whether points exist); the `showEmptyLabel: true` path (a finished-ride card, which never
|
||||||
|
transitions live) was left as its original text-only branch since it carries no such
|
||||||
|
risk and an existing test (`ride_map_test.dart`) already asserted no `FlutterMap` renders
|
||||||
|
there.
|
||||||
|
|
||||||
|
**Tests:** `flutter analyze` clean (pre-existing `deprecated_member_use` infos only,
|
||||||
|
unrelated to this ticket). `flutter test` green at 315 tests (was 316 before this ticket;
|
||||||
|
net -1 after removing two now-meaningless per-screen entry-point tests and adding one
|
||||||
|
shell-nav-bar test — see below). Two tests referencing the removed
|
||||||
|
`onOpenSettings`/`onOpenRoutes` callbacks were deleted outright (the concept no longer
|
||||||
|
exists); a new "shell nav bar" group in `test/widget_test.dart` asserts all four
|
||||||
|
`NavigationDestination`s are present and that tapping each calls back with the right
|
||||||
|
index. The two V3-04 background-map tests were rewritten to pump `ShellScaffold` and
|
||||||
|
assert on `shell-background-map`/`TileLayer` instead of the old per-screen `live-map` key
|
||||||
|
— both pass, including the lifecycle-driven tile-drop/restore test that exposed the bug
|
||||||
|
above.
|
||||||
|
|
||||||
|
**Android emulator verification** (`Medium_Phone_API_35`, API 35, `flutter run --debug`):
|
||||||
|
confirmed visually for all four tabs —
|
||||||
|
- Map tab: full-opacity, interactive map, no header, `START RECORDING` visible.
|
||||||
|
- Rides/Plan/Settings tabs: same map dimmed via the `0xB3000000` scrim, non-interactive,
|
||||||
|
no header, each tab's real content on top.
|
||||||
|
- Started a live recording (with `adb emu geo fix` supplying a location) and switched
|
||||||
|
tabs repeatedly: the point count and elapsed timer kept advancing across tab switches
|
||||||
|
(11 → 31 → 41 points, uninterrupted) and the camera position did not reset — confirming
|
||||||
|
the shared instance survives tab switches rather than being torn down and recreated.
|
||||||
|
- Backgrounding via the real HOME key (not just the synthetic lifecycle message the
|
||||||
|
widget test uses) dropped the map to a flat, untiled gray — the same lifecycle-based
|
||||||
|
tile-teardown from V3-04 firing correctly in the shell context, not just in tests.
|
||||||
|
- One visual red herring investigated and ruled out: a flat gray rectangle appeared
|
||||||
|
transiently in the dimmed background on the Rides/Plan tabs. Ruled out as an app bug by
|
||||||
|
reproducing it identically on two different tab screens at the same absolute screen
|
||||||
|
position, and by confirming a fresh app launch never shows it — it is flutter_map's
|
||||||
|
normal "tile chunk not yet loaded" placeholder at the low zoom level the background
|
||||||
|
map starts at before any location fix narrows it, not a shell defect.
|
||||||
|
- Known pre-existing rough edge, not a regression: the background map's follow-mode
|
||||||
|
moves the camera center to the latest point but does not adjust zoom on the first fix,
|
||||||
|
so a freshly-started ride's background map can look zoomed far out until the user
|
||||||
|
manually zooms. This behavior predates UI-01 (the same `_controller.move(point, current
|
||||||
|
Zoom)` call existed in the old standalone `_LiveMap`) and is out of scope here.
|
||||||
|
|
||||||
|
Deferred, not a gap: a widget test asserting Trip Detail/route-planner push-and-pop stays
|
||||||
|
within its own tab (rather than affecting `currentIndex`) was not written — the
|
||||||
|
`StatefulShellBranch`-per-tab nesting in `router.dart` structurally guarantees this via
|
||||||
|
go_router's own navigator-per-branch semantics, and it was verified manually on-device
|
||||||
|
that `router.dart`'s nested `GoRoute`s compile and resolve correctly. A future ticket
|
||||||
|
touching Rides/Plan navigation should add explicit coverage rather than relying on this
|
||||||
|
note.
|
||||||
|
|||||||
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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,12 +45,24 @@ class RideMap extends StatefulWidget {
|
|||||||
this.height = 320,
|
this.height = 320,
|
||||||
this.follow = false,
|
this.follow = false,
|
||||||
this.tileProvider,
|
this.tileProvider,
|
||||||
|
this.fill = false,
|
||||||
|
this.showEmptyLabel = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<TrackPoint> points;
|
final List<TrackPoint> points;
|
||||||
final List<Segment> segments;
|
final List<Segment> segments;
|
||||||
final double height;
|
final double height;
|
||||||
|
|
||||||
|
/// UI-01: fills whatever space the parent gives it (a `Positioned.fill`/`Expanded`
|
||||||
|
/// ancestor) instead of the fixed [height] -- for the persistent full-screen
|
||||||
|
/// background map behind every tab, where there is no card to size it.
|
||||||
|
final bool fill;
|
||||||
|
|
||||||
|
/// False for the persistent background: an idle app with no ride yet is the normal
|
||||||
|
/// state there, not an error to explain with text -- it should just look like a map,
|
||||||
|
/// centred on a neutral default, with nothing overlaid.
|
||||||
|
final bool showEmptyLabel;
|
||||||
|
|
||||||
/// V3-11: when supplied, tiles are read from (and written through to) the offline
|
/// V3-11: when supplied, tiles are read from (and written through to) the offline
|
||||||
/// tile cache instead of flutter_map's own uncapped default. Null falls back to
|
/// tile cache instead of flutter_map's own uncapped default. Null falls back to
|
||||||
/// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests.
|
/// ordinary networked tiles -- used whenever the cache isn't ready yet, or in tests.
|
||||||
@@ -118,13 +130,23 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
return grouped.values.toList();
|
return grouped.values.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `SizedBox.expand`, not `Positioned.fill` -- this widget is used both as a direct
|
||||||
|
// `Stack` child (fine either way) and wrapped in an `IgnorePointer` first (the shared
|
||||||
|
// shell background), where a `Positioned` is invalid because it isn't a direct child
|
||||||
|
// of the `Stack`. `SizedBox.expand` fills the available space either way.
|
||||||
|
Widget _sized({required Widget child}) =>
|
||||||
|
widget.fill ? SizedBox.expand(child: child) : SizedBox(height: widget.height, child: child);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final hasPoints = widget.points.isNotEmpty;
|
||||||
|
|
||||||
if (widget.points.isEmpty) {
|
// The `showEmptyLabel && !hasPoints` case (a finished ride card with no points, e.g.
|
||||||
return SizedBox(
|
// a corrupt/empty trip) renders text only, no map underneath -- that usage never
|
||||||
height: widget.height,
|
// transitions live, so there's no risk to the element tree here.
|
||||||
|
if (!hasPoints && widget.showEmptyLabel) {
|
||||||
|
return _sized(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No path recorded',
|
'No path recorded',
|
||||||
@@ -134,24 +156,34 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final all = [
|
// UI-01 bug, found by a widget test: the empty and non-empty states used to return
|
||||||
|
// structurally different widget trees (a bare `FlutterMap` versus
|
||||||
|
// `ClipRRect > FlutterMap`) for the `showEmptyLabel: false` background-map usage.
|
||||||
|
// The moment a live ride's first point arrived, Flutter treated that as a full
|
||||||
|
// element swap rather than a rebuild of the same element -- unmounting the old
|
||||||
|
// `FlutterMap` out from under a `MapController` that a `didUpdateWidget`-scheduled
|
||||||
|
// post-frame callback was about to use, which threw "Looking up a deactivated
|
||||||
|
// widget's ancestor is unsafe." Building exactly one shape always for this usage,
|
||||||
|
// varying only the `MapOptions`/children by `hasPoints`, removes the swap entirely.
|
||||||
|
geo.Bounds? bounds;
|
||||||
|
if (hasPoints) {
|
||||||
|
bounds = geo.bounds([
|
||||||
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
|
for (final p in widget.points) geo.LatLon(p.latitude, p.longitude),
|
||||||
];
|
]);
|
||||||
final bounds = geo.bounds(all)!;
|
}
|
||||||
final polylines = _buildPolylines(colors);
|
final polylines = hasPoints ? _buildPolylines(colors) : const <Polyline>[];
|
||||||
|
|
||||||
return SizedBox(
|
final map = ClipRRect(
|
||||||
height: widget.height,
|
|
||||||
// The map draws to the edge of its box; clipping keeps it from painting over
|
// The map draws to the edge of its box; clipping keeps it from painting over
|
||||||
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
// adjacent controls, which osmdroid did until it was explicitly bounded.
|
||||||
child: ClipRRect(
|
borderRadius: widget.fill ? BorderRadius.zero : BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: FlutterMap(
|
child: FlutterMap(
|
||||||
mapController: _controller,
|
mapController: _controller,
|
||||||
options: MapOptions(
|
options: MapOptions(
|
||||||
initialCameraFit: bounds.isDegenerate
|
initialCameraFit: (bounds == null || bounds.isDegenerate)
|
||||||
// Every point at one spot — a parked "ride". Fitting this would zoom to
|
// No points yet, or every point at one spot (a parked "ride") --
|
||||||
// infinity, so centre and use a sane street-level zoom instead.
|
// fitting a degenerate box would zoom to infinity, so centre on a
|
||||||
|
// neutral or last-known point at a sane street-level zoom instead.
|
||||||
? null
|
? null
|
||||||
: CameraFit.bounds(
|
: CameraFit.bounds(
|
||||||
bounds: LatLngBounds(
|
bounds: LatLngBounds(
|
||||||
@@ -163,12 +195,18 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
// and renders an empty grid.
|
// and renders an empty grid.
|
||||||
maxZoom: maxTileZoom,
|
maxZoom: maxTileZoom,
|
||||||
),
|
),
|
||||||
initialCenter: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
initialCenter: bounds == null
|
||||||
initialZoom: bounds.isDegenerate ? shortRideZoom : maxTileZoom,
|
? const ll.LatLng(0, 0)
|
||||||
|
: ll.LatLng(bounds.centerLat, bounds.centerLon),
|
||||||
|
initialZoom: bounds == null
|
||||||
|
? 2
|
||||||
|
: (bounds.isDegenerate ? shortRideZoom : maxTileZoom),
|
||||||
maxZoom: maxTileZoom,
|
maxZoom: maxTileZoom,
|
||||||
interactionOptions: const InteractionOptions(
|
interactionOptions: hasPoints
|
||||||
|
? const InteractionOptions(
|
||||||
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
|
||||||
),
|
)
|
||||||
|
: const InteractionOptions(flags: InteractiveFlag.none),
|
||||||
onPositionChanged: !widget.follow
|
onPositionChanged: !widget.follow
|
||||||
? null
|
? null
|
||||||
: (position, hasGesture) {
|
: (position, hasGesture) {
|
||||||
@@ -195,8 +233,9 @@ class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
|
|||||||
PolylineLayer(polylines: polylines),
|
PolylineLayer(polylines: polylines),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return _sized(child: map);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One polyline per speed run within each segment.
|
/// One polyline per speed run within each segment.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import '../../domain/models.dart';
|
|||||||
import '../../recording/location_source.dart';
|
import '../../recording/location_source.dart';
|
||||||
import '../../recording/wakelock_controller.dart';
|
import '../../recording/wakelock_controller.dart';
|
||||||
import '../../telemetry/telemetry.dart';
|
import '../../telemetry/telemetry.dart';
|
||||||
import '../components/ride_map.dart';
|
|
||||||
import '../components/stats.dart';
|
import '../components/stats.dart';
|
||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
@@ -38,16 +37,7 @@ class RecordUiState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class RecordScreen extends ConsumerStatefulWidget {
|
class RecordScreen extends ConsumerStatefulWidget {
|
||||||
const RecordScreen({
|
const RecordScreen({super.key});
|
||||||
super.key,
|
|
||||||
this.onOpenTrips,
|
|
||||||
this.onOpenSettings,
|
|
||||||
this.onOpenRoutes,
|
|
||||||
});
|
|
||||||
|
|
||||||
final VoidCallback? onOpenTrips;
|
|
||||||
final VoidCallback? onOpenSettings;
|
|
||||||
final VoidCallback? onOpenRoutes;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<RecordScreen> createState() => _RecordScreenState();
|
ConsumerState<RecordScreen> createState() => _RecordScreenState();
|
||||||
@@ -158,7 +148,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
// colour scheme straight off a locally-built theme rather than `Theme.of(context)`,
|
// colour scheme straight off a locally-built theme rather than `Theme.of(context)`,
|
||||||
// which would still report the app-wide (dark) theme here.
|
// which would still report the app-wide (dark) theme here.
|
||||||
final theme = mountedMode ? ripprMountedTheme() : Theme.of(context);
|
final theme = mountedMode ? ripprMountedTheme() : Theme.of(context);
|
||||||
final colors = theme.colorScheme;
|
|
||||||
final engine = ref.watch(recordingEngineProvider);
|
final engine = ref.watch(recordingEngineProvider);
|
||||||
final trip = ref.watch(activeTripProvider).valueOrNull;
|
final trip = ref.watch(activeTripProvider).valueOrNull;
|
||||||
final units = ref.watch(unitSystemProvider);
|
final units = ref.watch(unitSystemProvider);
|
||||||
@@ -173,7 +162,13 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
: (_now - trip.startedAt).clamp(0, 1 << 62),
|
: (_now - trip.startedAt).clamp(0, 1 << 62),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// UI-01: no header, no embedded map card -- the map is the shared background behind
|
||||||
|
// this whole tab (`AppShell`), full opacity here. A quick mounted-mode toggle used to
|
||||||
|
// live in a header row; that row is gone, and the toggle's only home for now is
|
||||||
|
// Settings' existing switch (see the ticket's Outcome for why this is a deliberate,
|
||||||
|
// temporary trade rather than an oversight -- UI-05 restyles this screen properly).
|
||||||
return Theme(data: theme, child: Scaffold(
|
return Theme(data: theme, child: Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
// Scrollable, but still centred when there is room.
|
// Scrollable, but still centred when there is room.
|
||||||
//
|
//
|
||||||
// With a ride active the card grows to six stat rows, which overflows a short
|
// With a ride active the card grows to six stat rows, which overflows a short
|
||||||
@@ -190,56 +185,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'RIPPR',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 34,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
letterSpacing: 6,
|
|
||||||
color: colors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
key: const Key('open-routes'),
|
|
||||||
onPressed: widget.onOpenRoutes,
|
|
||||||
child: const Text('Routes'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: widget.onOpenTrips,
|
|
||||||
child: const Text('Rides'),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
key: const Key('mounted-mode-toggle'),
|
|
||||||
icon: Icon(mountedMode
|
|
||||||
? Icons.motorcycle
|
|
||||||
: Icons.motorcycle_outlined),
|
|
||||||
tooltip: mountedMode
|
|
||||||
? 'Mounted mode on'
|
|
||||||
: 'Mounted mode off',
|
|
||||||
color: mountedMode ? colors.primary : null,
|
|
||||||
onPressed: () async {
|
|
||||||
final next = !mountedMode;
|
|
||||||
await ref.read(configProvider)?.setMountedMode(next);
|
|
||||||
ref.read(mountedModeProvider.notifier).state = next;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
key: const Key('open-settings'),
|
|
||||||
icon: const Icon(Icons.settings_outlined),
|
|
||||||
tooltip: 'Settings',
|
|
||||||
onPressed: widget.onOpenSettings,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// Current speed, not max: a max figure only moves when you beat it, which
|
// Current speed, not max: a max figure only moves when you beat it, which
|
||||||
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
|
// reads as a frozen screen while riding steadily. This is the 2.0.1 fix and
|
||||||
// it must not regress.
|
// it must not regress.
|
||||||
@@ -252,17 +197,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// V3-04: RecordingEngine has no idea this exists -- it is fed
|
|
||||||
// entirely from a Drift stream, the same seam the trip-detail map
|
|
||||||
// already uses. Off by default (mapEnabledProvider) until battery
|
|
||||||
// impact is measured (V3-13), and only while a ride is actually
|
|
||||||
// active -- an idle screen has nothing to draw and no reason to
|
|
||||||
// hold a tile layer alive.
|
|
||||||
if (ui.trip != null && ref.watch(mapEnabledProvider)) ...[
|
|
||||||
_LiveMap(tripId: ui.trip!.id),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -352,28 +286,6 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thin adapter from the two live Drift streams to [RideMap]. Kept out of
|
|
||||||
/// [_RecordScreenState] so a provider read/write cycle here can't be confused with the
|
|
||||||
/// engine's own state -- this widget only ever reads.
|
|
||||||
class _LiveMap extends ConsumerWidget {
|
|
||||||
const _LiveMap({required this.tripId});
|
|
||||||
|
|
||||||
final int tripId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final points = ref.watch(livePointsProvider(tripId)).valueOrNull ?? const [];
|
|
||||||
final segments = ref.watch(liveSegmentsProvider(tripId)).valueOrNull ?? const [];
|
|
||||||
return RideMap(
|
|
||||||
key: const Key('live-map'),
|
|
||||||
points: points,
|
|
||||||
segments: segments,
|
|
||||||
height: 220,
|
|
||||||
follow: true,
|
|
||||||
tileProvider: ref.watch(cachedTileProviderProvider),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Controls extends StatelessWidget {
|
class _Controls extends StatelessWidget {
|
||||||
const _Controls({
|
const _Controls({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/// Ported from `com.rippr.ui.RipprNavHost`.
|
/// UI-01: a persistent 4-tab shell (`StatefulShellRoute`), replacing the push/pop stack
|
||||||
///
|
/// rooted at Record that this app used through v3. Each branch keeps its own navigator,
|
||||||
/// Three destinations, same shape as the Compose original. `go_router` replaces
|
/// so pushing Trip Detail from Rides -- or the route planner from Plan -- doesn't
|
||||||
/// `navigation-compose`; the path parameter is parsed explicitly, mirroring the Kotlin
|
/// disturb the other tabs' state or the active tab index.
|
||||||
/// comment about ids arriving as the wrong type when the argument type is left implicit.
|
|
||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'app_shell.dart';
|
||||||
import 'detail/trip_detail_screen.dart';
|
import 'detail/trip_detail_screen.dart';
|
||||||
import 'record/record_screen.dart';
|
import 'record/record_screen.dart';
|
||||||
import 'routes/route_planner_screen.dart';
|
import 'routes/route_planner_screen.dart';
|
||||||
@@ -17,65 +17,81 @@ import 'trips/trips_screen.dart';
|
|||||||
|
|
||||||
abstract final class Routes {
|
abstract final class Routes {
|
||||||
static const record = '/';
|
static const record = '/';
|
||||||
static const trips = '/trips';
|
static const trips = '/rides';
|
||||||
static const tripDetail = '/trip/:tripId';
|
static const tripDetail = '/rides/:tripId';
|
||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
static const routes = '/routes';
|
static const routes = '/plan';
|
||||||
static const routePlanner = '/routes/:routeId';
|
static const routePlanner = '/plan/:routeId';
|
||||||
|
|
||||||
static String detailFor(int tripId) => '/trip/$tripId';
|
static String detailFor(int tripId) => '/rides/$tripId';
|
||||||
static String plannerFor(int routeId) => '/routes/$routeId';
|
static String plannerFor(int routeId) => '/plan/$routeId';
|
||||||
}
|
}
|
||||||
|
|
||||||
GoRouter buildRouter() => GoRouter(
|
GoRouter buildRouter() => GoRouter(
|
||||||
initialLocation: Routes.record,
|
initialLocation: Routes.record,
|
||||||
|
routes: [
|
||||||
|
StatefulShellRoute.indexedStack(
|
||||||
|
builder: (context, state, navigationShell) =>
|
||||||
|
AppShell(navigationShell: navigationShell),
|
||||||
|
branches: [
|
||||||
|
// Branch 0: Map. The Record screen, headerless -- see AppShell's doc comment.
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(path: Routes.record, builder: (context, state) => const RecordScreen()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Branch 1: Rides. Trip Detail pushes within this branch's own navigator.
|
||||||
|
StatefulShellBranch(
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.record,
|
path: Routes.trips,
|
||||||
builder: (context, state) => RecordScreen(
|
builder: (context, state) => TripsScreen(
|
||||||
onOpenTrips: () => context.push(Routes.trips),
|
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
||||||
onOpenSettings: () => context.push(Routes.settings),
|
|
||||||
onOpenRoutes: () => context.push(Routes.routes),
|
|
||||||
),
|
),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: ':tripId',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
||||||
|
if (id == null) return const _NotFound();
|
||||||
|
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Branch 2: Plan. The route planner pushes within this branch's own navigator.
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.routes,
|
path: Routes.routes,
|
||||||
builder: (context, state) => RoutesListScreen(
|
builder: (context, state) => RoutesListScreen(
|
||||||
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
|
onOpenRoute: (id) => context.push(Routes.plannerFor(id)),
|
||||||
onBack: () => context.pop(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.routePlanner,
|
path: ':routeId',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
|
final id = int.tryParse(state.pathParameters['routeId'] ?? '');
|
||||||
if (id == null) return const _NotFound();
|
if (id == null) return const _NotFound();
|
||||||
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
|
return RoutePlannerScreen(routeId: id, onBack: () => context.pop());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Branch 3: Settings.
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.settings,
|
path: Routes.settings,
|
||||||
builder: (context, state) =>
|
builder: (context, state) => const SettingsScreen(),
|
||||||
SettingsScreen(onBack: () => context.pop()),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
],
|
||||||
path: Routes.trips,
|
|
||||||
builder: (context, state) => TripsScreen(
|
|
||||||
onOpenTrip: (id) => context.push(Routes.detailFor(id)),
|
|
||||||
onBack: () => context.pop(),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
GoRoute(
|
|
||||||
path: Routes.tripDetail,
|
|
||||||
builder: (context, state) {
|
|
||||||
// Parsed explicitly. A malformed or missing id must land on a real screen
|
|
||||||
// saying so, never on a blank one or a crash.
|
|
||||||
final id = int.tryParse(state.pathParameters['tripId'] ?? '');
|
|
||||||
if (id == null) {
|
|
||||||
return const _NotFound();
|
|
||||||
}
|
|
||||||
return TripDetailScreen(tripId: id, onBack: () => context.pop());
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
errorBuilder: (context, state) => const _NotFound(),
|
errorBuilder: (context, state) => const _NotFound(),
|
||||||
@@ -86,13 +102,11 @@ class _NotFound extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
appBar: AppBar(
|
body: Center(
|
||||||
leading: IconButton(
|
child: TextButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
onPressed: () => context.canPop() ? context.pop() : context.go(Routes.record),
|
||||||
onPressed: () =>
|
child: const Text('That ride could not be found. Back'),
|
||||||
context.canPop() ? context.pop() : context.go(Routes.record),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: const Center(child: Text('That ride could not be found.')),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ import '../components/stats.dart';
|
|||||||
import '../format.dart';
|
import '../format.dart';
|
||||||
|
|
||||||
class RoutesListScreen extends ConsumerWidget {
|
class RoutesListScreen extends ConsumerWidget {
|
||||||
const RoutesListScreen({super.key, this.onOpenRoute, this.onBack});
|
const RoutesListScreen({super.key, this.onOpenRoute});
|
||||||
|
|
||||||
final void Function(int routeId)? onOpenRoute;
|
final void Function(int routeId)? onOpenRoute;
|
||||||
final VoidCallback? onBack;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -21,7 +20,10 @@ class RoutesListScreen extends ConsumerWidget {
|
|||||||
final routes = ref.watch(routePlansProvider).valueOrNull ?? const <RoutePlan>[];
|
final routes = ref.watch(routePlansProvider).valueOrNull ?? const <RoutePlan>[];
|
||||||
final units = ref.watch(unitSystemProvider);
|
final units = ref.watch(unitSystemProvider);
|
||||||
|
|
||||||
|
// UI-01: no back/title header -- Plan is a tab now. "New route" is functional, not
|
||||||
|
// chrome, so it stays, right-aligned on its own.
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@@ -30,15 +32,6 @@ class RoutesListScreen extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
|
||||||
key: const Key('back'),
|
|
||||||
onPressed: onBack,
|
|
||||||
child: const Text('‹ Record'),
|
|
||||||
),
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(left: 8),
|
|
||||||
child: Text('ROUTES', style: TextStyle(letterSpacing: 2)),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('new-route'),
|
key: const Key('new-route'),
|
||||||
|
|||||||
@@ -13,23 +13,16 @@ import '../../config/config.dart';
|
|||||||
import '../../domain/models.dart';
|
import '../../domain/models.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerWidget {
|
class SettingsScreen extends ConsumerWidget {
|
||||||
const SettingsScreen({super.key, this.onBack});
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
final VoidCallback? onBack;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final config = ref.watch(configProvider);
|
final config = ref.watch(configProvider);
|
||||||
|
|
||||||
|
// UI-01: no AppBar -- Settings is a tab now, reached via the persistent bottom nav,
|
||||||
|
// not a pushed screen with somewhere to back out to.
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: Colors.transparent,
|
||||||
leading: IconButton(
|
|
||||||
key: const Key('back'),
|
|
||||||
icon: const Icon(Icons.arrow_back),
|
|
||||||
onPressed: onBack,
|
|
||||||
),
|
|
||||||
title: const Text('Settings'),
|
|
||||||
),
|
|
||||||
// Config loads once at startup (see main.dart) and is normally already present by
|
// Config loads once at startup (see main.dart) and is normally already present by
|
||||||
// the time anyone reaches this screen, but the seam is real: show a spinner rather
|
// the time anyone reaches this screen, but the seam is real: show a spinner rather
|
||||||
// than crash on a null read in the small window before it resolves.
|
// than crash on a null read in the small window before it resolves.
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ final _selectionProvider = StateProvider.autoDispose<Set<int>>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
class TripsScreen extends ConsumerWidget {
|
class TripsScreen extends ConsumerWidget {
|
||||||
const TripsScreen({super.key, this.onOpenTrip, this.onBack});
|
const TripsScreen({super.key, this.onOpenTrip});
|
||||||
|
|
||||||
final void Function(int tripId)? onOpenTrip;
|
final void Function(int tripId)? onOpenTrip;
|
||||||
final VoidCallback? onBack;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -44,18 +43,22 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
void clearSelection() =>
|
void clearSelection() =>
|
||||||
ref.read(_selectionProvider.notifier).state = <int>{};
|
ref.read(_selectionProvider.notifier).state = <int>{};
|
||||||
|
|
||||||
|
// UI-01: no back/title header -- Rides is a tab now. The selection toolbar (Cancel/
|
||||||
|
// count/Merge/Delete) is functional, not chrome, so it stays -- it only appears
|
||||||
|
// while `selecting` is true.
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
if (selecting)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (selecting) ...[
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: clearSelection,
|
onPressed: clearSelection,
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
@@ -67,27 +70,7 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
style: TextStyle(fontSize: 14, color: colors.outline),
|
style: TextStyle(fontSize: 14, color: colors.outline),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
] else ...[
|
|
||||||
TextButton(
|
|
||||||
key: const Key('back'),
|
|
||||||
onPressed: onBack,
|
|
||||||
child: const Text('‹ Record'),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 8),
|
|
||||||
child: Text(
|
|
||||||
'RIDES',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: 3,
|
|
||||||
color: colors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (selecting) ...[
|
|
||||||
TextButton(
|
TextButton(
|
||||||
key: const Key('merge'),
|
key: const Key('merge'),
|
||||||
onPressed: canMerge
|
onPressed: canMerge
|
||||||
@@ -104,7 +87,6 @@ class TripsScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:rippr/src/domain/models.dart';
|
|||||||
import 'package:rippr/src/ui/activity_display.dart';
|
import 'package:rippr/src/ui/activity_display.dart';
|
||||||
import 'package:rippr/src/recording/location_source.dart';
|
import 'package:rippr/src/recording/location_source.dart';
|
||||||
import 'package:rippr/src/recording/wakelock_controller.dart';
|
import 'package:rippr/src/recording/wakelock_controller.dart';
|
||||||
|
import 'package:rippr/src/ui/app_shell.dart';
|
||||||
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||||
import 'package:rippr/src/ui/format.dart';
|
import 'package:rippr/src/ui/format.dart';
|
||||||
import 'package:rippr/src/ui/record/record_screen.dart';
|
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||||
@@ -203,58 +204,37 @@ void main() {
|
|||||||
expect(colour, isNot(Colors.black));
|
expect(colour, isNot(Colors.black));
|
||||||
});
|
});
|
||||||
|
|
||||||
screenTest('a settings entry point exists and is wired (V3-02)', (tester) async {
|
// UI-01: Settings/Routes/Rides entry points are no longer per-screen callback
|
||||||
var opened = false;
|
// buttons on RecordScreen -- they're destinations on the shell's persistent bottom
|
||||||
await tester.pumpWidget(
|
// nav bar. See the `AppShell`/`ShellScaffold` group below for their coverage.
|
||||||
host(RecordScreen(onOpenSettings: () => opened = true)),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byKey(const Key('open-settings')), findsOneWidget);
|
screenTest('the background map appears only while the map toggle is on '
|
||||||
await tester.tap(find.byKey(const Key('open-settings')));
|
'(V3-04, moved to the shell by UI-01)', (tester) async {
|
||||||
await tester.pumpAndSettle();
|
// Toggle off: the shell must not construct the map at all -- not just hidden.
|
||||||
|
|
||||||
expect(opened, isTrue,
|
|
||||||
reason: 'the button must actually invoke the callback that navigates');
|
|
||||||
});
|
|
||||||
|
|
||||||
screenTest('a routes entry point exists and is wired (V3-07)', (tester) async {
|
|
||||||
var opened = false;
|
|
||||||
await tester.pumpWidget(
|
|
||||||
host(RecordScreen(onOpenRoutes: () => opened = true)),
|
|
||||||
);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.byKey(const Key('open-routes')), findsOneWidget);
|
|
||||||
await tester.tap(find.byKey(const Key('open-routes')));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(opened, isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
screenTest('the live map appears only while recording and the toggle is on '
|
|
||||||
'(V3-04)', (tester) async {
|
|
||||||
// Idle, toggle on: no trip to draw, so no map at all.
|
|
||||||
await tester.pumpWidget(host(const RecordScreen()));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
expect(find.byKey(const Key('live-map')), findsNothing);
|
|
||||||
|
|
||||||
// Recording, toggle off: RecordingEngine has produced a trip, but the map must
|
|
||||||
// not be constructed at all -- not just hidden.
|
|
||||||
await repo.startTrip(1000);
|
await repo.startTrip(1000);
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await pumpLive(
|
||||||
await pumpLive(tester, host(const RecordScreen(), map: false));
|
tester,
|
||||||
expect(find.byKey(const Key('live-map')), findsNothing);
|
host(
|
||||||
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||||
|
map: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('shell-background-map')), findsNothing);
|
||||||
expect(find.byType(FlutterMap), findsNothing);
|
expect(find.byType(FlutterMap), findsNothing);
|
||||||
|
|
||||||
// Recording, toggle on: the map is drawn.
|
// Toggle on: the map is drawn.
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
await pumpLive(tester, host(const RecordScreen()));
|
await pumpLive(
|
||||||
expect(find.byKey(const Key('live-map')), findsOneWidget);
|
tester,
|
||||||
|
host(
|
||||||
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('shell-background-map')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
screenTest('backgrounding the app drops the tile layer (V3-04)',
|
screenTest('backgrounding the app drops the tile layer (V3-04, moved to the '
|
||||||
(tester) async {
|
'shell by UI-01)', (tester) async {
|
||||||
final h = await repo.startTrip(1000);
|
final h = await repo.startTrip(1000);
|
||||||
await repo.appendPoints([
|
await repo.appendPoints([
|
||||||
TrackPoint(
|
TrackPoint(
|
||||||
@@ -267,7 +247,12 @@ void main() {
|
|||||||
altitudeM: 1000.0,
|
altitudeM: 1000.0,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
await pumpLive(tester, host(const RecordScreen()));
|
await pumpLive(
|
||||||
|
tester,
|
||||||
|
host(
|
||||||
|
ShellScaffold(currentIndex: 0, onDestinationSelected: (_) {}, child: const RecordScreen()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
expect(find.byType(TileLayer), findsOneWidget,
|
expect(find.byType(TileLayer), findsOneWidget,
|
||||||
reason: 'foregrounded: tiles render normally');
|
reason: 'foregrounded: tiles render normally');
|
||||||
@@ -278,6 +263,7 @@ void main() {
|
|||||||
await tester.binding.defaultBinaryMessenger
|
await tester.binding.defaultBinaryMessenger
|
||||||
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
|
.handlePlatformMessage('flutter/lifecycle', message, (_) {});
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.byType(TileLayer), findsNothing,
|
expect(find.byType(TileLayer), findsNothing,
|
||||||
reason: 'backgrounded: no tile layer means no tile request can fire');
|
reason: 'backgrounded: no tile layer means no tile request can fire');
|
||||||
@@ -288,6 +274,7 @@ void main() {
|
|||||||
await tester.binding.defaultBinaryMessenger
|
await tester.binding.defaultBinaryMessenger
|
||||||
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
|
.handlePlatformMessage('flutter/lifecycle', resumed, (_) {});
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
expect(find.byType(TileLayer), findsOneWidget,
|
expect(find.byType(TileLayer), findsOneWidget,
|
||||||
reason: 'foregrounding again must resume tiles');
|
reason: 'foregrounding again must resume tiles');
|
||||||
});
|
});
|
||||||
@@ -364,6 +351,41 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('shell nav bar', () {
|
||||||
|
// UI-01: Settings/Routes/Rides are no longer per-screen callback buttons on
|
||||||
|
// RecordScreen -- they're destinations on the shell's persistent bottom nav bar.
|
||||||
|
// This replaces the old "a settings/routes entry point exists and is wired"
|
||||||
|
// per-screen tests.
|
||||||
|
screenTest('all four destinations are present and switching tabs calls back '
|
||||||
|
'with the tapped index', (tester) async {
|
||||||
|
var lastIndex = -1;
|
||||||
|
await tester.pumpWidget(host(
|
||||||
|
ShellScaffold(
|
||||||
|
currentIndex: 0,
|
||||||
|
onDestinationSelected: (i) => lastIndex = i,
|
||||||
|
child: const RecordScreen(),
|
||||||
|
),
|
||||||
|
map: false,
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('shell-nav-bar')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('nav-map')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('nav-rides')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('nav-plan')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('nav-settings')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('nav-settings')));
|
||||||
|
expect(lastIndex, 3);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('nav-rides')));
|
||||||
|
expect(lastIndex, 1);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('nav-plan')));
|
||||||
|
expect(lastIndex, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('trips list', () {
|
group('trips list', () {
|
||||||
screenTest('empty state explains what to do', (tester) async {
|
screenTest('empty state explains what to do', (tester) async {
|
||||||
await tester.pumpWidget(host(const TripsScreen()));
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
|||||||
Reference in New Issue
Block a user