From 4c534089ae146871e854dd779f0ec761e3bd70a6 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Mon, 20 Apr 2026 15:13:58 -0400 Subject: [PATCH] track last open book --- lib/main.dart | 23 ++- lib/screens/home_screen.dart | 103 +++++++++---- lib/screens/tracker_screen.dart | 3 + lib/services/last_location.dart | 135 +++++++++++++++++ lib/services/last_location.freezed.dart | 188 ++++++++++++++++++++++++ lib/services/last_location.g.dart | 19 +++ lib/services/tracker_service.dart | 69 +++------ lib/services/tracker_storage.dart | 5 +- test/screens/home_screen_test.dart | 175 ++++++++++++++++++++-- test/services/last_location_test.dart | 68 +++++++++ test/services/tracker_service_test.dart | 45 ++---- 11 files changed, 707 insertions(+), 126 deletions(-) create mode 100644 lib/services/last_location.dart create mode 100644 lib/services/last_location.freezed.dart create mode 100644 lib/services/last_location.g.dart create mode 100644 test/services/last_location_test.dart diff --git a/lib/main.dart b/lib/main.dart index 9849aed..4445995 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,23 +3,41 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'screens/home_screen.dart'; +import 'services/last_location.dart'; import 'services/tracker_storage.dart'; import 'widgets/theme.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final SharedPreferences prefs = await SharedPreferences.getInstance(); + // Load the last location synchronously before the root navigator can fire + // its initial didPush and clear the saved value. + final LastLocation? pending = LastLocationService(prefs).load(); runApp( ProviderScope( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + pendingRestoreProvider.overrideWithValue(pending), + ], child: const BibleTrackerApp(), ), ); } -class BibleTrackerApp extends StatelessWidget { +class BibleTrackerApp extends ConsumerStatefulWidget { const BibleTrackerApp({super.key}); + @override + ConsumerState createState() => _BibleTrackerAppState(); +} + +class _BibleTrackerAppState extends ConsumerState { + late final LastLocationObserver _observer = LastLocationObserver(( + LastLocation? location, + ) async { + await ref.read(lastLocationServiceProvider).save(location); + }); + @override Widget build(BuildContext context) { return MaterialApp( @@ -29,6 +47,7 @@ class BibleTrackerApp extends StatelessWidget { darkTheme: AppTheme.dark(), themeMode: ThemeMode.system, home: const HomeScreen(), + navigatorObservers: [_observer], ); } } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index c6defde..a0c6f3c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,49 +1,92 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../data/bible.dart'; import '../models/tracker.dart'; +import '../services/last_location.dart'; import '../services/tracker_service.dart'; import '../widgets/theme.dart'; import '../widgets/tracker_tile.dart'; +import 'book_screen.dart'; import 'create_tracker_screen.dart'; import 'tracker_screen.dart'; -class HomeScreen extends ConsumerWidget { +class HomeScreen extends ConsumerStatefulWidget { const HomeScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final AsyncValue> state = ref.watch(trackerServiceProvider); + ConsumerState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends ConsumerState { + bool _restoreAttempted = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _maybeRestore()); + } + + void _maybeRestore() { + if (_restoreAttempted || !mounted) return; + _restoreAttempted = true; + + final LastLocation? pending = ref.read(pendingRestoreProvider); + if (pending == null) return; + final List trackers = ref.read(trackerServiceProvider); + final Tracker? tracker = trackers.firstWhereOrNull( + (Tracker t) => t.id == pending.trackerId, + ); + if (tracker == null) return; + + final NavigatorState navigator = Navigator.of(context); + navigator.push( + MaterialPageRoute( + settings: RouteSettings(name: '/tracker/${tracker.id}'), + builder: (_) => TrackerScreen(trackerId: tracker.id), + ), + ); + final int? bookIndex = pending.bookIndex; + if (bookIndex != null && bookIndex >= 0 && bookIndex < Bible.books.length) { + navigator.push( + MaterialPageRoute( + settings: RouteSettings( + name: '/tracker/${tracker.id}/book/$bookIndex', + ), + builder: (_) => + BookScreen(trackerId: tracker.id, bookIndex: bookIndex), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final List trackers = ref.watch(trackerServiceProvider); return Scaffold( appBar: AppBar(title: const Text('My Bible Tracker')), - body: state.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (Object error, StackTrace stack) => - Center(child: Text('Something went wrong:\n$error')), - data: (List trackers) { - if (trackers.isEmpty) { - return const _EmptyState(); - } - return ListView.separated( - padding: const EdgeInsets.all(AppTokens.spaceL), - itemCount: trackers.length, - separatorBuilder: (_, __) => - const SizedBox(height: AppTokens.spaceM), - itemBuilder: (BuildContext context, int index) { - final Tracker tracker = trackers[index]; - return TrackerTile( - tracker: tracker, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TrackerScreen(trackerId: tracker.id), + body: trackers.isEmpty + ? const _EmptyState() + : ListView.separated( + padding: const EdgeInsets.all(AppTokens.spaceL), + itemCount: trackers.length, + separatorBuilder: (_, _) => + const SizedBox(height: AppTokens.spaceM), + itemBuilder: (BuildContext context, int index) { + final Tracker tracker = trackers[index]; + return TrackerTile( + tracker: tracker, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + settings: RouteSettings(name: '/tracker/${tracker.id}'), + builder: (_) => TrackerScreen(trackerId: tracker.id), + ), ), - ), - onLongPress: () => _showTrackerMenu(context, ref, tracker), - ); - }, - ); - }, - ), + onLongPress: () => _showTrackerMenu(context, ref, tracker), + ); + }, + ), floatingActionButton: FloatingActionButton.extended( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const CreateTrackerScreen()), diff --git a/lib/screens/tracker_screen.dart b/lib/screens/tracker_screen.dart index 423813c..81860a6 100644 --- a/lib/screens/tracker_screen.dart +++ b/lib/screens/tracker_screen.dart @@ -248,6 +248,9 @@ class _BooksSliver extends ConsumerWidget { accent: accent, onTap: () => Navigator.of(context).push( MaterialPageRoute( + settings: RouteSettings( + name: '/tracker/${tracker.id}/book/${book.index}', + ), builder: (_) => BookScreen(trackerId: tracker.id, bookIndex: book.index), ), diff --git a/lib/services/last_location.dart b/lib/services/last_location.dart new file mode 100644 index 0000000..0fe6228 --- /dev/null +++ b/lib/services/last_location.dart @@ -0,0 +1,135 @@ +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 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/` or + /// `/tracker//book/` 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 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); + } catch (_) { + return null; + } + } + + Future save(LastLocation? location) async { + if (location == null) { + await _prefs.remove(_key); + return; + } + await _prefs.setString(_key, jsonEncode(location.toJson())); + } +} + +final Provider lastLocationServiceProvider = + Provider( + (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 pendingRestoreProvider = Provider( + (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 route, Route? previousRoute) { + _notify(route); + } + + @override + void didPop(Route route, Route? previousRoute) { + _notify(previousRoute); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + _notify(newRoute); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _notify(previousRoute); + } + + void _notify(Route? 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); + } +} diff --git a/lib/services/last_location.freezed.dart b/lib/services/last_location.freezed.dart new file mode 100644 index 0000000..da72e47 --- /dev/null +++ b/lib/services/last_location.freezed.dart @@ -0,0 +1,188 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'last_location.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models', +); + +LastLocation _$LastLocationFromJson(Map json) { + return _LastLocation.fromJson(json); +} + +/// @nodoc +mixin _$LastLocation { + String get trackerId => throw _privateConstructorUsedError; + int? get bookIndex => throw _privateConstructorUsedError; + + /// Serializes this LastLocation to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of LastLocation + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $LastLocationCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LastLocationCopyWith<$Res> { + factory $LastLocationCopyWith( + LastLocation value, + $Res Function(LastLocation) then, + ) = _$LastLocationCopyWithImpl<$Res, LastLocation>; + @useResult + $Res call({String trackerId, int? bookIndex}); +} + +/// @nodoc +class _$LastLocationCopyWithImpl<$Res, $Val extends LastLocation> + implements $LastLocationCopyWith<$Res> { + _$LastLocationCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LastLocation + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? trackerId = null, Object? bookIndex = freezed}) { + return _then( + _value.copyWith( + trackerId: null == trackerId + ? _value.trackerId + : trackerId // ignore: cast_nullable_to_non_nullable + as String, + bookIndex: freezed == bookIndex + ? _value.bookIndex + : bookIndex // ignore: cast_nullable_to_non_nullable + as int?, + ) + as $Val, + ); + } +} + +/// @nodoc +abstract class _$$LastLocationImplCopyWith<$Res> + implements $LastLocationCopyWith<$Res> { + factory _$$LastLocationImplCopyWith( + _$LastLocationImpl value, + $Res Function(_$LastLocationImpl) then, + ) = __$$LastLocationImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String trackerId, int? bookIndex}); +} + +/// @nodoc +class __$$LastLocationImplCopyWithImpl<$Res> + extends _$LastLocationCopyWithImpl<$Res, _$LastLocationImpl> + implements _$$LastLocationImplCopyWith<$Res> { + __$$LastLocationImplCopyWithImpl( + _$LastLocationImpl _value, + $Res Function(_$LastLocationImpl) _then, + ) : super(_value, _then); + + /// Create a copy of LastLocation + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? trackerId = null, Object? bookIndex = freezed}) { + return _then( + _$LastLocationImpl( + trackerId: null == trackerId + ? _value.trackerId + : trackerId // ignore: cast_nullable_to_non_nullable + as String, + bookIndex: freezed == bookIndex + ? _value.bookIndex + : bookIndex // ignore: cast_nullable_to_non_nullable + as int?, + ), + ); + } +} + +/// @nodoc +@JsonSerializable() +class _$LastLocationImpl extends _LastLocation { + const _$LastLocationImpl({required this.trackerId, this.bookIndex}) + : super._(); + + factory _$LastLocationImpl.fromJson(Map json) => + _$$LastLocationImplFromJson(json); + + @override + final String trackerId; + @override + final int? bookIndex; + + @override + String toString() { + return 'LastLocation(trackerId: $trackerId, bookIndex: $bookIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LastLocationImpl && + (identical(other.trackerId, trackerId) || + other.trackerId == trackerId) && + (identical(other.bookIndex, bookIndex) || + other.bookIndex == bookIndex)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, trackerId, bookIndex); + + /// Create a copy of LastLocation + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LastLocationImplCopyWith<_$LastLocationImpl> get copyWith => + __$$LastLocationImplCopyWithImpl<_$LastLocationImpl>(this, _$identity); + + @override + Map toJson() { + return _$$LastLocationImplToJson(this); + } +} + +abstract class _LastLocation extends LastLocation { + const factory _LastLocation({ + required final String trackerId, + final int? bookIndex, + }) = _$LastLocationImpl; + const _LastLocation._() : super._(); + + factory _LastLocation.fromJson(Map json) = + _$LastLocationImpl.fromJson; + + @override + String get trackerId; + @override + int? get bookIndex; + + /// Create a copy of LastLocation + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LastLocationImplCopyWith<_$LastLocationImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/services/last_location.g.dart b/lib/services/last_location.g.dart new file mode 100644 index 0000000..0471a17 --- /dev/null +++ b/lib/services/last_location.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'last_location.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$LastLocationImpl _$$LastLocationImplFromJson(Map json) => + _$LastLocationImpl( + trackerId: json['trackerId'] as String, + bookIndex: (json['bookIndex'] as num?)?.toInt(), + ); + +Map _$$LastLocationImplToJson(_$LastLocationImpl instance) => + { + 'trackerId': instance.trackerId, + 'bookIndex': instance.bookIndex, + }; diff --git a/lib/services/tracker_service.dart b/lib/services/tracker_service.dart index e423e12..b536a4e 100644 --- a/lib/services/tracker_service.dart +++ b/lib/services/tracker_service.dart @@ -8,32 +8,18 @@ import 'tracker_storage.dart'; /// Manages the list of [Tracker]s and exposes them as state to the UI. /// -/// Kept as a plain service (no repository) because storage access is -/// simple read/write-all and there are no complex queries or caches. -class TrackerService extends StateNotifier>> { +/// Initial state is loaded synchronously from [TrackerStorage] so the first +/// frame already has the full list. Mutations persist asynchronously. +class TrackerService extends StateNotifier> { TrackerService(this._storage, {Uuid? uuid}) : _uuid = uuid ?? const Uuid(), - super(const AsyncValue>.loading()) { - unawaitedLoad(); - } + super(_storage.loadAll()); final TrackerStorage _storage; final Uuid _uuid; - @visibleForTesting - Future unawaitedLoad() => _load(); - - Future _load() async { - try { - final List trackers = await _storage.loadAll(); - state = AsyncValue>.data(trackers); - } catch (error, stack) { - state = AsyncValue>.error(error, stack); - } - } - Future _persist(List trackers) async { - state = AsyncValue>.data(trackers); + state = trackers; await _storage.saveAll(trackers); } @@ -41,27 +27,21 @@ class TrackerService extends StateNotifier>> { final Tracker tracker = Tracker( id: _uuid.v4(), name: name, - // ignore: deprecated_member_use - colorValue: color.value, + colorValue: color.toARGB32(), startDate: DateTime.now(), ); - final List next = [ - ...state.value ?? const [], - tracker, - ]; + final List next = [...state, tracker]; await _persist(next); return tracker; } Future updateMeta(String id, {String? name, Color? color}) async { - final List current = state.value ?? const []; - final List next = current + final List next = state .map( (Tracker tracker) => tracker.id == id ? tracker.copyWith( name: name ?? tracker.name, - // ignore: deprecated_member_use - colorValue: color?.value ?? tracker.colorValue, + colorValue: color?.toARGB32() ?? tracker.colorValue, ) : tracker, ) @@ -70,8 +50,7 @@ class TrackerService extends StateNotifier>> { } Future delete(String id) async { - final List current = state.value ?? const []; - final List next = current + final List next = state .where((Tracker tracker) => tracker.id != id) .toList(growable: false); await _persist(next); @@ -79,8 +58,7 @@ class TrackerService extends StateNotifier>> { Future toggleChapter(String id, int bookIndex, int chapter) async { final String key = Bible.chapterKey(bookIndex, chapter); - final List current = state.value ?? const []; - final List next = current + final List next = state .map((Tracker tracker) { if (tracker.id != id) return tracker; final Set updated = {...tracker.readChapters}; @@ -99,8 +77,7 @@ class TrackerService extends StateNotifier>> { required bool read, }) async { final Book book = Bible.books[bookIndex]; - final List current = state.value ?? const []; - final List next = current + final List next = state .map((Tracker tracker) { if (tracker.id != id) return tracker; final Set updated = {...tracker.readChapters}; @@ -119,8 +96,7 @@ class TrackerService extends StateNotifier>> { } Future reset(String id) async { - final List current = state.value ?? const []; - final List next = current + final List next = state .map( (Tracker tracker) => tracker.id == id ? tracker.copyWith(readChapters: const {}) @@ -131,18 +107,17 @@ class TrackerService extends StateNotifier>> { } } -final StateNotifierProvider>> -trackerServiceProvider = - StateNotifierProvider>>( - (Ref ref) => TrackerService(ref.read(trackerStorageProvider)), - ); +final StateNotifierProvider> +trackerServiceProvider = StateNotifierProvider>( + (Ref ref) => TrackerService(ref.read(trackerStorageProvider)), +); /// Convenience selector for a single tracker by id. final ProviderFamily trackerByIdProvider = Provider.family((Ref ref, String id) { - final AsyncValue> state = ref.watch(trackerServiceProvider); - return state.value?.cast().firstWhere( - (Tracker? t) => t?.id == id, - orElse: () => null, - ); + final List trackers = ref.watch(trackerServiceProvider); + for (final Tracker tracker in trackers) { + if (tracker.id == id) return tracker; + } + return null; }); diff --git a/lib/services/tracker_storage.dart b/lib/services/tracker_storage.dart index c21040b..0c5fdbb 100644 --- a/lib/services/tracker_storage.dart +++ b/lib/services/tracker_storage.dart @@ -17,7 +17,10 @@ class TrackerStorage { final SharedPreferences _prefs; - Future> loadAll() async { + /// Synchronous because the underlying [SharedPreferences] getter already + /// works against an in-memory cache. Keeping this sync lets the service + /// start up with real data on the first frame, with no async-load race. + List loadAll() { final String? raw = _prefs.getString(_storageKey); if (raw == null || raw.isEmpty) { return []; diff --git a/test/screens/home_screen_test.dart b/test/screens/home_screen_test.dart index 3069e78..813106e 100644 --- a/test/screens/home_screen_test.dart +++ b/test/screens/home_screen_test.dart @@ -1,4 +1,11 @@ +import 'dart:convert'; + +import 'package:bible_tracker/data/bible.dart'; +import 'package:bible_tracker/models/tracker.dart'; +import 'package:bible_tracker/screens/book_screen.dart'; import 'package:bible_tracker/screens/home_screen.dart'; +import 'package:bible_tracker/screens/tracker_screen.dart'; +import 'package:bible_tracker/services/last_location.dart'; import 'package:bible_tracker/services/tracker_service.dart'; import 'package:bible_tracker/services/tracker_storage.dart'; import 'package:bible_tracker/widgets/theme.dart'; @@ -7,32 +14,44 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; -Future _wrapped() async { - SharedPreferences.setMockInitialValues({}); +Future _pumpApp({ + Map? initialPrefs, + LastLocation? pending, +}) async { + SharedPreferences.setMockInitialValues(initialPrefs ?? {}); final prefs = await SharedPreferences.getInstance(); return ProviderScope( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], - child: MaterialApp(theme: AppTheme.light(), home: const HomeScreen()), + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + pendingRestoreProvider.overrideWithValue(pending), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const HomeScreen(), + navigatorObservers: [ + LastLocationObserver( + (LastLocation? loc) => LastLocationService(prefs).save(loc), + ), + ], + ), ); } +String _seedTrackerJson(Tracker tracker) => + jsonEncode(>[tracker.toJson()]); + void main() { testWidgets('shows empty state when no trackers exist', (tester) async { - await tester.pumpWidget(await _wrapped()); + await tester.pumpWidget(await _pumpApp()); await tester.pumpAndSettle(); expect(find.text('Start your first tracker'), findsOneWidget); expect(find.text('New Tracker'), findsOneWidget); }); testWidgets('creating a tracker shows it on the list', (tester) async { - await tester.pumpWidget(await _wrapped()); + await tester.pumpWidget(await _pumpApp()); await tester.pumpAndSettle(); - final ProviderScope scope = tester.widget( - find.byType(ProviderScope), - ); - expect(scope, isNotNull); - final BuildContext context = tester.element(find.byType(HomeScreen)); final ProviderContainer container = ProviderScope.containerOf(context); await container @@ -43,4 +62,138 @@ void main() { expect(find.text('My Plan'), findsOneWidget); expect(find.text('0%'), findsOneWidget); }); + + testWidgets( + 'restores tracker + book on launch when pending location matches', + (tester) async { + final tracker = Tracker( + id: 'seed-1', + name: 'Persisted', + colorValue: 0xFF3F51B5, + startDate: DateTime(2024), + ); + const int bookIndex = 42; // John + + await tester.pumpWidget( + await _pumpApp( + initialPrefs: { + 'bible_tracker.trackers.v1': _seedTrackerJson(tracker), + }, + pending: LastLocation(trackerId: tracker.id, bookIndex: bookIndex), + ), + ); + await tester.pumpAndSettle(); + + // TrackerScreen sits underneath BookScreen and is offstage. + expect( + find.byType(TrackerScreen, skipOffstage: false), + findsOneWidget, + ); + expect(find.byType(BookScreen), findsOneWidget); + expect(find.text(Bible.books[bookIndex].name), findsWidgets); + }, + ); + + testWidgets('restores tracker only when no book index is saved', ( + tester, + ) async { + final tracker = Tracker( + id: 'seed-2', + name: 'Resumed', + colorValue: 0xFF3F51B5, + startDate: DateTime(2024), + ); + await tester.pumpWidget( + await _pumpApp( + initialPrefs: { + 'bible_tracker.trackers.v1': _seedTrackerJson(tracker), + }, + pending: LastLocation(trackerId: tracker.id), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TrackerScreen), findsOneWidget); + expect(find.byType(BookScreen), findsNothing); + expect(find.text('Resumed'), findsWidgets); + }); + + testWidgets('skips restore when the saved tracker no longer exists', ( + tester, + ) async { + await tester.pumpWidget( + await _pumpApp(pending: const LastLocation(trackerId: 'missing')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(TrackerScreen), findsNothing); + expect(find.byType(BookScreen), findsNothing); + expect(find.text('Start your first tracker'), findsOneWidget); + }); + + testWidgets('navigating into a book persists the location via the observer', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + sharedPreferencesProvider.overrideWithValue(prefs), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: const HomeScreen(), + navigatorObservers: [ + LastLocationObserver( + (LastLocation? loc) => LastLocationService(prefs).save(loc), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final BuildContext context = tester.element(find.byType(HomeScreen)); + final ProviderContainer container = ProviderScope.containerOf(context); + final tracker = await container + .read(trackerServiceProvider.notifier) + .create(name: 'Plan', color: const Color(0xFF3F51B5)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Plan')); + await tester.pumpAndSettle(); + + expect( + LastLocationService(prefs).load(), + LastLocation(trackerId: tracker.id), + ); + + await tester.tap(find.text('Genesis')); + await tester.pumpAndSettle(); + + expect( + LastLocationService(prefs).load(), + LastLocation(trackerId: tracker.id, bookIndex: 0), + ); + + // Pop back to the tracker screen — the persisted location should + // shrink back to "tracker only". + final NavigatorState navigator = tester.state( + find.byType(Navigator).first, + ); + navigator.pop(); + await tester.pumpAndSettle(); + + expect( + LastLocationService(prefs).load(), + LastLocation(trackerId: tracker.id), + ); + + // Pop all the way to home — location should clear. + navigator.pop(); + await tester.pumpAndSettle(); + + expect(LastLocationService(prefs).load(), isNull); + }); } diff --git a/test/services/last_location_test.dart b/test/services/last_location_test.dart new file mode 100644 index 0000000..665b7b5 --- /dev/null +++ b/test/services/last_location_test.dart @@ -0,0 +1,68 @@ +import 'package:bible_tracker/services/last_location.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('LastLocation.tryParseRoute', () { + test('parses tracker-only route', () { + final parsed = LastLocation.tryParseRoute('/tracker/abc'); + expect(parsed, const LastLocation(trackerId: 'abc')); + }); + + test('parses tracker + book route', () { + final parsed = LastLocation.tryParseRoute('/tracker/abc/book/12'); + expect(parsed, const LastLocation(trackerId: 'abc', bookIndex: 12)); + }); + + test('returns null for unrelated routes', () { + expect(LastLocation.tryParseRoute(null), isNull); + expect(LastLocation.tryParseRoute('/'), isNull); + expect(LastLocation.tryParseRoute('/settings'), isNull); + expect(LastLocation.tryParseRoute('/tracker'), isNull); + expect( + LastLocation.tryParseRoute('/tracker/abc/book/not-a-number'), + isNull, + ); + }); + + test('toRouteName round-trips', () { + const a = LastLocation(trackerId: 'abc'); + const b = LastLocation(trackerId: 'abc', bookIndex: 0); + expect(LastLocation.tryParseRoute(a.toRouteName()), a); + expect(LastLocation.tryParseRoute(b.toRouteName()), b); + }); + }); + + group('LastLocationService', () { + test('returns null when nothing is persisted', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = LastLocationService(prefs); + expect(service.load(), isNull); + }); + + test('round-trips a location through SharedPreferences', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final service = LastLocationService(prefs); + + const location = LastLocation(trackerId: 't1', bookIndex: 7); + await service.save(location); + expect(service.load(), location); + + await service.save(null); + expect(service.load(), isNull); + }); + + test('load returns null when stored JSON is corrupt', () async { + SharedPreferences.setMockInitialValues({ + 'bible_tracker.last_location.v1': 'not-json', + }); + final prefs = await SharedPreferences.getInstance(); + final service = LastLocationService(prefs); + expect(service.load(), isNull); + }); + }); +} diff --git a/test/services/tracker_service_test.dart b/test/services/tracker_service_test.dart index f0ec0d5..62e61bf 100644 --- a/test/services/tracker_service_test.dart +++ b/test/services/tracker_service_test.dart @@ -15,30 +15,18 @@ Future _container() async { ); } -Future> _waitForData(ProviderContainer container) async { - while (true) { - final AsyncValue> state = container.read( - trackerServiceProvider, - ); - if (state.hasValue) return state.value!; - await Future.delayed(const Duration(milliseconds: 5)); - } -} - void main() { TestWidgetsFlutterBinding.ensureInitialized(); - test('starts empty after initial load', () async { + test('starts empty when nothing is persisted', () async { final container = await _container(); addTearDown(container.dispose); - final List data = await _waitForData(container); - expect(data, isEmpty); + expect(container.read(trackerServiceProvider), isEmpty); }); test('create adds a tracker with the given name and color', () async { final container = await _container(); addTearDown(container.dispose); - await _waitForData(container); final tracker = await container .read(trackerServiceProvider.notifier) @@ -46,14 +34,12 @@ void main() { expect(tracker.name, 'My Plan'); expect(tracker.colorValue, const Color(0xFF123456).toARGB32()); - expect(container.read(trackerServiceProvider).value, [tracker]); + expect(container.read(trackerServiceProvider), [tracker]); }); test('toggleChapter adds and then removes chapter', () async { final container = await _container(); addTearDown(container.dispose); - await _waitForData(container); - final service = container.read(trackerServiceProvider.notifier); final tracker = await service.create( name: 'Plan', @@ -61,18 +47,17 @@ void main() { ); await service.toggleChapter(tracker.id, 0, 1); - Tracker current = container.read(trackerServiceProvider).value!.single; + Tracker current = container.read(trackerServiceProvider).single; expect(current.readChapters, {Bible.chapterKey(0, 1)}); await service.toggleChapter(tracker.id, 0, 1); - current = container.read(trackerServiceProvider).value!.single; + current = container.read(trackerServiceProvider).single; expect(current.readChapters, isEmpty); }); test('setBookRead marks all chapters of the book', () async { final container = await _container(); addTearDown(container.dispose); - await _waitForData(container); final service = container.read(trackerServiceProvider.notifier); final tracker = await service.create( name: 'Plan', @@ -80,10 +65,7 @@ void main() { ); await service.setBookRead(tracker.id, 0, read: true); - final Tracker current = container - .read(trackerServiceProvider) - .value! - .single; + final Tracker current = container.read(trackerServiceProvider).single; final Book genesis = Bible.books[0]; expect(current.readChapters.length, genesis.chapterCount); for (int chapter = 1; chapter <= genesis.chapterCount; chapter++) { @@ -94,7 +76,6 @@ void main() { test('reset clears only the target tracker', () async { final container = await _container(); addTearDown(container.dispose); - await _waitForData(container); final service = container.read(trackerServiceProvider.notifier); final a = await service.create(name: 'A', color: const Color(0xFF111111)); final b = await service.create(name: 'B', color: const Color(0xFF222222)); @@ -103,7 +84,7 @@ void main() { await service.reset(a.id); - final List current = container.read(trackerServiceProvider).value!; + final List current = container.read(trackerServiceProvider); expect( current.firstWhere((Tracker t) => t.id == a.id).readChapters, isEmpty, @@ -117,13 +98,12 @@ void main() { test('delete removes the tracker', () async { final container = await _container(); addTearDown(container.dispose); - await _waitForData(container); final service = container.read(trackerServiceProvider.notifier); final a = await service.create(name: 'A', color: const Color(0xFF111111)); await service.create(name: 'B', color: const Color(0xFF222222)); await service.delete(a.id); - final List current = container.read(trackerServiceProvider).value!; + final List current = container.read(trackerServiceProvider); expect(current.length, 1); expect(current.single.name, 'B'); }); @@ -135,7 +115,6 @@ void main() { final first = ProviderContainer( overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], ); - await _waitForData(first); final created = await first .read(trackerServiceProvider.notifier) .create(name: 'Persisted', color: const Color(0xFFAABBCC)); @@ -148,7 +127,7 @@ void main() { overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], ); addTearDown(second.dispose); - final List loaded = await _waitForData(second); + final List loaded = second.read(trackerServiceProvider); expect(loaded.length, 1); expect(loaded.single.name, 'Persisted'); @@ -158,7 +137,6 @@ void main() { test('updateMeta changes name and color', () async { final container = await _container(); addTearDown(container.dispose); - await _waitForData(container); final service = container.read(trackerServiceProvider.notifier); final tracker = await service.create( name: 'Old', @@ -170,10 +148,7 @@ void main() { name: 'New', color: const Color(0xFF222222), ); - final Tracker current = container - .read(trackerServiceProvider) - .value! - .single; + final Tracker current = container.read(trackerServiceProvider).single; expect(current.name, 'New'); expect(current.colorValue, const Color(0xFF222222).toARGB32()); });