bible-tracker/lib/services/last_location.dart

136 lines
4.1 KiB
Dart
Raw Permalink Normal View History

2026-04-20 19:13:58 +00:00
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);
}
}