V3-04: live map on the recording screen

Feeds RideMap from watchPointsForTrip/watchSegmentsForTrip via autoDispose.family providers. RecordingEngine stays map-free (enforced by architecture_test.dart). RideMap gained lifecycle-aware tile teardown and follow-mode, both shared with the trip-detail map.
This commit is contained in:
2026-08-17 14:45:37 -05:00
parent 0267d0108c
commit f3c7e2d6b4
8 changed files with 239 additions and 13 deletions

View File

@@ -110,3 +110,14 @@ final mapEnabledProvider = StateProvider<bool>(
final unitSystemProvider = StateProvider<UnitSystem>(
(ref) => ref.watch(configProvider)?.unitSystem ?? UnitSystem.metric,
);
/// Live path for the recording screen's map (V3-04). `.family` + `autoDispose` so the
/// stream tears down the moment the record screen stops watching it — no leftover
/// subscription ticking away once a ride ends or the map toggle goes off.
final livePointsProvider = StreamProvider.autoDispose.family<List<TrackPoint>, int>(
(ref, tripId) => ref.watch(databaseProvider).watchPointsForTrip(tripId),
);
final liveSegmentsProvider = StreamProvider.autoDispose.family<List<Segment>, int>(
(ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId),
);

View File

@@ -261,6 +261,15 @@ class AppDatabase extends _$AppDatabase {
return rows.map(_toSegment).toList();
}
/// Feeds the live map (V3-04): segment boundaries are what turn a pause into a visible
/// gap instead of a straight line drawn across the gap.
Stream<List<domain.Segment>> watchSegmentsForTrip(int tripId) =>
(select(segments)
..where((s) => s.tripId.equals(tripId))
..orderBy([(s) => OrderingTerm.asc(s.id)]))
.watch()
.map((rows) => rows.map(_toSegment).toList());
/// The segment currently being recorded into, if any.
Future<domain.Segment?> openSegment(int tripId) async {
final row =
@@ -343,6 +352,19 @@ class AppDatabase extends _$AppDatabase {
return rows.map(_toPoint).toList();
}
/// Feeds the live map (V3-04). Updates on each writer flush (~2 s), not per fix —
/// the stream is backed by the same table the batched writer flushes into, so there is
/// nothing extra to throttle.
Stream<List<domain.TrackPoint>> watchPointsForTrip(int tripId) =>
(select(trackPoints)
..where((p) => p.tripId.equals(tripId))
..orderBy([
(p) => OrderingTerm.asc(p.segmentId),
(p) => OrderingTerm.asc(p.id),
]))
.watch()
.map((rows) => rows.map(_toPoint).toList());
Future<List<domain.TrackPoint>> pointsForSegment(int segmentId) async {
final rows =
await (select(trackPoints)

View File

@@ -43,21 +43,62 @@ class RideMap extends StatefulWidget {
required this.points,
required this.segments,
this.height = 320,
this.follow = false,
});
final List<TrackPoint> points;
final List<Segment> segments;
final double height;
/// V3-04: keep the latest point centred while recording. A manual pan/pinch turns
/// this off until the widget is rebuilt fresh (e.g. a new ride) — chasing the rider
/// back to centre after they deliberately looked elsewhere would be worse than not
/// following at all.
final bool follow;
@override
State<RideMap> createState() => _RideMapState();
}
class _RideMapState extends State<RideMap> {
class _RideMapState extends State<RideMap> with WidgetsBindingObserver {
final _controller = MapController();
late bool _following = widget.follow;
/// V3-04: a pocketed phone must cost exactly what it costs today. Backgrounding drops
/// the tile layer entirely rather than merely pausing it — flutter_map has no pause
/// primitive, and an app resume already triggers a full rebuild anyway.
bool _backgrounded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didUpdateWidget(RideMap old) {
super.didUpdateWidget(old);
if (!_following || widget.points.isEmpty || _backgrounded) return;
final last = widget.points.last;
// A manual camera move already exists once the controller has been used; jumping
// straight to the latest fix keeps the map from ever being one flush behind.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_following) return;
_controller.move(ll.LatLng(last.latitude, last.longitude), _controller.camera.zoom);
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
final backgrounded = state != AppLifecycleState.resumed;
if (backgrounded != _backgrounded && mounted) {
setState(() => _backgrounded = backgrounded);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose();
super.dispose();
}
@@ -122,15 +163,28 @@ class _RideMapState extends State<RideMap> {
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.pinchZoom | InteractiveFlag.drag,
),
onPositionChanged: !widget.follow
? null
: (position, hasGesture) {
// Only a real pan/pinch turns following off -- the programmatic
// moves this widget makes to chase the rider must not cancel
// themselves out.
if (hasGesture && _following) {
setState(() => _following = false);
}
},
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(),
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
panBuffer: 0,
),
// Omitted entirely while backgrounded -- not just visually hidden -- so no
// tile request can fire off-screen. See the lifecycle observer above.
if (!_backgrounded)
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: tileUserAgent,
maxNativeZoom: maxTileZoom.toInt(),
// Respect OSM's usage policy: render what is looked at, never bulk prefetch.
panBuffer: 0,
),
PolylineLayer(polylines: polylines),
],
),

View File

@@ -10,6 +10,7 @@ import '../../app/providers.dart';
import '../../domain/models.dart';
import '../../recording/location_source.dart';
import '../../telemetry/telemetry.dart';
import '../components/ride_map.dart';
import '../components/stats.dart';
import '../format.dart';
@@ -187,6 +188,17 @@ class _RecordScreenState extends ConsumerState<RecordScreen> {
),
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(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -274,6 +286,28 @@ 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,
);
}
}
class _Controls extends StatelessWidget {
const _Controls({
required this.ui,