136 lines
4.1 KiB
Dart
136 lines
4.1 KiB
Dart
|
|
import 'dart:convert';
|
||
|
|
|
||
|
|
import 'package:flutter/widgets.dart';
|
||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
||
|
|
|
||
|
|
import 'tracker_storage.dart';
|
||
|
|
|
||
|
|
part 'last_location.freezed.dart';
|
||
|
|
part 'last_location.g.dart';
|
||
|
|
|
||
|
|
/// The deepest route the user was viewing when the app was last active.
|
||
|
|
///
|
||
|
|
/// Encoded via [toRouteName] into [Route.settings.name] so a single
|
||
|
|
/// [NavigatorObserver] can track it without every screen needing to know
|
||
|
|
/// about persistence.
|
||
|
|
@freezed
|
||
|
|
class LastLocation with _$LastLocation {
|
||
|
|
const LastLocation._();
|
||
|
|
|
||
|
|
const factory LastLocation({required String trackerId, int? bookIndex}) =
|
||
|
|
_LastLocation;
|
||
|
|
|
||
|
|
factory LastLocation.fromJson(Map<String, dynamic> json) =>
|
||
|
|
_$LastLocationFromJson(json);
|
||
|
|
|
||
|
|
static const String _trackerPrefix = '/tracker/';
|
||
|
|
|
||
|
|
String toRouteName() => bookIndex == null
|
||
|
|
? '$_trackerPrefix$trackerId'
|
||
|
|
: '$_trackerPrefix$trackerId/book/$bookIndex';
|
||
|
|
|
||
|
|
/// Parses a route name such as `/tracker/<id>` or
|
||
|
|
/// `/tracker/<id>/book/<bookIndex>` back into a [LastLocation].
|
||
|
|
///
|
||
|
|
/// Returns null for the root `/` route, unnamed routes (modals, dialogs),
|
||
|
|
/// and malformed names.
|
||
|
|
static LastLocation? tryParseRoute(String? name) {
|
||
|
|
if (name == null) return null;
|
||
|
|
final List<String> parts = name
|
||
|
|
.split('/')
|
||
|
|
.where((String s) => s.isNotEmpty)
|
||
|
|
.toList(growable: false);
|
||
|
|
if (parts.isEmpty || parts.first != 'tracker') return null;
|
||
|
|
if (parts.length == 2) return LastLocation(trackerId: parts[1]);
|
||
|
|
if (parts.length == 4 && parts[2] == 'book') {
|
||
|
|
final int? idx = int.tryParse(parts[3]);
|
||
|
|
if (idx != null) {
|
||
|
|
return LastLocation(trackerId: parts[1], bookIndex: idx);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Reads and writes the most recent [LastLocation] to [SharedPreferences].
|
||
|
|
class LastLocationService {
|
||
|
|
LastLocationService(this._prefs);
|
||
|
|
|
||
|
|
static const String _key = 'bible_tracker.last_location.v1';
|
||
|
|
|
||
|
|
final SharedPreferences _prefs;
|
||
|
|
|
||
|
|
LastLocation? load() {
|
||
|
|
final String? raw = _prefs.getString(_key);
|
||
|
|
if (raw == null || raw.isEmpty) return null;
|
||
|
|
try {
|
||
|
|
return LastLocation.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||
|
|
} catch (_) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<void> save(LastLocation? location) async {
|
||
|
|
if (location == null) {
|
||
|
|
await _prefs.remove(_key);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await _prefs.setString(_key, jsonEncode(location.toJson()));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
final Provider<LastLocationService> lastLocationServiceProvider =
|
||
|
|
Provider<LastLocationService>(
|
||
|
|
(Ref ref) => LastLocationService(ref.read(sharedPreferencesProvider)),
|
||
|
|
);
|
||
|
|
|
||
|
|
/// Populated at startup in `main.dart` with the location that was persisted
|
||
|
|
/// during the previous session. Reading it once avoids a race where the root
|
||
|
|
/// navigator's initial `didPush` clears the saved value before the UI has
|
||
|
|
/// had a chance to restore it.
|
||
|
|
final Provider<LastLocation?> pendingRestoreProvider = Provider<LastLocation?>(
|
||
|
|
(Ref ref) => null,
|
||
|
|
);
|
||
|
|
|
||
|
|
/// Writes the top-most named route to [_onChange] whenever navigation
|
||
|
|
/// changes. Unnamed routes (bottom sheets, dialogs, the create-tracker page)
|
||
|
|
/// are ignored so they don't clobber the underlying tracker/book location.
|
||
|
|
class LastLocationObserver extends NavigatorObserver {
|
||
|
|
LastLocationObserver(this._onChange);
|
||
|
|
|
||
|
|
final void Function(LastLocation? location) _onChange;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
|
|
_notify(route);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
|
|
_notify(previousRoute);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||
|
|
_notify(newRoute);
|
||
|
|
}
|
||
|
|
|
||
|
|
@override
|
||
|
|
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||
|
|
_notify(previousRoute);
|
||
|
|
}
|
||
|
|
|
||
|
|
void _notify(Route<dynamic>? route) {
|
||
|
|
final String? name = route?.settings.name;
|
||
|
|
if (name == null) return;
|
||
|
|
if (name == '/') {
|
||
|
|
_onChange(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
final LastLocation? parsed = LastLocation.tryParseRoute(name);
|
||
|
|
if (parsed != null) _onChange(parsed);
|
||
|
|
}
|
||
|
|
}
|