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:
@@ -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),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user