import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/region/geo.dart'; import 'saved_place.dart'; import 'saved_places.dart'; /// What the map is pointed at. sealed class MapTarget { const MapTarget(); /// The value persisted as the preferred target, or null if it is not a /// sensible thing to reopen on. String? get storageValue; /// The point the map draws a marker on for this target, or null for none. /// /// On the interface rather than in the map widget so that adding a target /// without deciding what it looks like on the map stops compiling. GeoPoint? get markedPoint; } /// Follow the device: the blue dot stays centred until the user pans away. class FollowUser extends MapTarget { const FollowUser(); @override String? get storageValue => 'follow'; /// None: the blue dot marks the real position, and a pin on top of it would /// say the same thing twice, less precisely. @override GeoPoint? get markedPoint => null; @override bool operator ==(Object other) => other is FollowUser; @override int get hashCode => 'follow'.hashCode; } /// Sit on a saved place. class PlaceTarget extends MapTarget { const PlaceTarget(this.place); final SavedPlace place; @override String? get storageValue => 'place:${place.id}'; @override GeoPoint? get markedPoint => place.point; @override bool operator ==(Object other) => other is PlaceTarget && other.place.id == place.id; @override int get hashCode => place.id.hashCode; } /// A point the user has gone to look at without keeping it. /// /// What a search produces. The map centres on it and marks it, and the recentre /// button comes back to it, but it is never persisted as what the app reopens /// on: looking something up is not the same commitment as saving a place, and /// the place the user chose to open on should survive a search. class PointTarget extends MapTarget { const PointTarget({required this.point, required this.label}); final GeoPoint point; /// What to call it on screen, and the name it takes if the user saves it. final String label; @override String? get storageValue => null; @override GeoPoint? get markedPoint => point; @override bool operator ==(Object other) => other is PointTarget && other.point == point && other.label == label; @override int get hashCode => Object.hash(point, label); } /// The whole region, and wherever the user has panned to. /// /// Also what following degrades to the moment the user drags the map: the /// camera stops chasing them, which is the behaviour every map app has trained /// people to expect. class FreeTarget extends MapTarget { const FreeTarget(); @override String? get storageValue => null; @override GeoPoint? get markedPoint => null; @override bool operator ==(Object other) => other is FreeTarget; @override int get hashCode => 'free'.hashCode; } /// Remembers what the app should open on. abstract interface class PreferredTargetStore { Future load(); Future save(String? value); } class SharedPreferencesPreferredTargetStore implements PreferredTargetStore { const SharedPreferencesPreferredTargetStore(); static const String storageKey = 'nuvolari.preferred_target.v1'; @override Future load() async => (await SharedPreferences.getInstance()).getString(storageKey); @override Future save(String? value) async { final prefs = await SharedPreferences.getInstance(); if (value == null) { await prefs.remove(storageKey); } else { await prefs.setString(storageKey, value); } } } class InMemoryPreferredTargetStore implements PreferredTargetStore { InMemoryPreferredTargetStore([this._value]); String? _value; @override Future load() async => _value; @override Future save(String? value) async => _value = value; } final preferredTargetStoreProvider = Provider( (ref) => const SharedPreferencesPreferredTargetStore(), ); /// The map's current target, and the one it will reopen on. /// /// One notion rather than two, because "the place I marked as preferred" and /// "what the app shows me when I open it" are the same thing to the person /// using it. Selecting from the dropdown sets both. class MapTargetController extends Notifier { @override MapTarget build() { // Places load asynchronously, so the preferred target is resolved once they // arrive rather than guessed now. ref.listen>>(savedPlacesProvider, ( previous, next, ) { if (previous?.hasValue ?? false) return; final places = next.value; if (places != null) unawaited(_restorePreferred(places)); }, fireImmediately: true); return const FreeTarget(); } bool _restored = false; Future _restorePreferred(List places) async { if (_restored) return; _restored = true; final stored = await ref.read(preferredTargetStoreProvider).load(); if (stored == null) return; if (stored == 'follow') { state = const FollowUser(); return; } if (stored.startsWith('place:')) { final id = stored.substring('place:'.length); for (final place in places) { if (place.id == id) { state = PlaceTarget(place); return; } } // The preferred place was deleted. Forget it rather than reopening on // something that no longer exists. await ref.read(preferredTargetStoreProvider).save(null); } } /// Selects [target] and remembers it as what to reopen on. /// /// Not for [PointTarget]: a searched point is transient, and persisting it /// would clear the place the user chose to open on. Use [previewPoint]. Future select(MapTarget target) async { assert( target is! PointTarget, 'a searched point is transient - use previewPoint', ); state = target; await ref.read(preferredTargetStoreProvider).save(target.storageValue); } /// Points the map at [point] without changing what it reopens on. void previewPoint({required GeoPoint point, required String label}) { state = PointTarget(point: point, label: label); } /// Drops out of following without changing what the app reopens on. /// /// Called when the user drags the map while it is chasing them: they want to /// look somewhere else *now*, which is not the same as changing their mind /// about what the app should do next launch. void releaseFollow() { if (state is FollowUser) state = const FreeTarget(); } /// Forgets a place that has been deleted, if it was the target. Future forget(String placeId) async { final current = state; if (current is PlaceTarget && current.place.id == placeId) { state = const FreeTarget(); await ref.read(preferredTargetStoreProvider).save(null); } } } final mapTargetProvider = NotifierProvider( MapTargetController.new, );