track last open book

This commit is contained in:
mplorentz 2026-04-20 15:13:58 -04:00
parent 27338de5f6
commit 4c534089ae
11 changed files with 707 additions and 126 deletions

View file

@ -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<void> 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: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
overrides: <Override>[
sharedPreferencesProvider.overrideWithValue(prefs),
pendingRestoreProvider.overrideWithValue(pending),
],
child: const BibleTrackerApp(),
),
);
}
class BibleTrackerApp extends StatelessWidget {
class BibleTrackerApp extends ConsumerStatefulWidget {
const BibleTrackerApp({super.key});
@override
ConsumerState<BibleTrackerApp> createState() => _BibleTrackerAppState();
}
class _BibleTrackerAppState extends ConsumerState<BibleTrackerApp> {
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: <NavigatorObserver>[_observer],
);
}
}

View file

@ -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<List<Tracker>> state = ref.watch(trackerServiceProvider);
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
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<Tracker> 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<void>(
MaterialPageRoute<void>(
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<void>(
MaterialPageRoute<void>(
settings: RouteSettings(
name: '/tracker/${tracker.id}/book/$bookIndex',
),
builder: (_) =>
BookScreen(trackerId: tracker.id, bookIndex: bookIndex),
),
);
}
}
@override
Widget build(BuildContext context) {
final List<Tracker> 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<Tracker> 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<void>(
MaterialPageRoute<void>(
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<void>(
MaterialPageRoute<void>(
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<void>(
MaterialPageRoute<void>(builder: (_) => const CreateTrackerScreen()),

View file

@ -248,6 +248,9 @@ class _BooksSliver extends ConsumerWidget {
accent: accent,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
settings: RouteSettings(
name: '/tracker/${tracker.id}/book/${book.index}',
),
builder: (_) =>
BookScreen(trackerId: tracker.id, bookIndex: book.index),
),

View file

@ -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<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);
}
}

View file

@ -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>(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<String, dynamic> 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<String, dynamic> 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<LastLocation> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View file

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'last_location.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$LastLocationImpl _$$LastLocationImplFromJson(Map<String, dynamic> json) =>
_$LastLocationImpl(
trackerId: json['trackerId'] as String,
bookIndex: (json['bookIndex'] as num?)?.toInt(),
);
Map<String, dynamic> _$$LastLocationImplToJson(_$LastLocationImpl instance) =>
<String, dynamic>{
'trackerId': instance.trackerId,
'bookIndex': instance.bookIndex,
};

View file

@ -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<AsyncValue<List<Tracker>>> {
/// Initial state is loaded synchronously from [TrackerStorage] so the first
/// frame already has the full list. Mutations persist asynchronously.
class TrackerService extends StateNotifier<List<Tracker>> {
TrackerService(this._storage, {Uuid? uuid})
: _uuid = uuid ?? const Uuid(),
super(const AsyncValue<List<Tracker>>.loading()) {
unawaitedLoad();
}
super(_storage.loadAll());
final TrackerStorage _storage;
final Uuid _uuid;
@visibleForTesting
Future<void> unawaitedLoad() => _load();
Future<void> _load() async {
try {
final List<Tracker> trackers = await _storage.loadAll();
state = AsyncValue<List<Tracker>>.data(trackers);
} catch (error, stack) {
state = AsyncValue<List<Tracker>>.error(error, stack);
}
}
Future<void> _persist(List<Tracker> trackers) async {
state = AsyncValue<List<Tracker>>.data(trackers);
state = trackers;
await _storage.saveAll(trackers);
}
@ -41,27 +27,21 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
final Tracker tracker = Tracker(
id: _uuid.v4(),
name: name,
// ignore: deprecated_member_use
colorValue: color.value,
colorValue: color.toARGB32(),
startDate: DateTime.now(),
);
final List<Tracker> next = <Tracker>[
...state.value ?? const <Tracker>[],
tracker,
];
final List<Tracker> next = <Tracker>[...state, tracker];
await _persist(next);
return tracker;
}
Future<void> updateMeta(String id, {String? name, Color? color}) async {
final List<Tracker> current = state.value ?? const <Tracker>[];
final List<Tracker> next = current
final List<Tracker> 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<AsyncValue<List<Tracker>>> {
}
Future<void> delete(String id) async {
final List<Tracker> current = state.value ?? const <Tracker>[];
final List<Tracker> next = current
final List<Tracker> next = state
.where((Tracker tracker) => tracker.id != id)
.toList(growable: false);
await _persist(next);
@ -79,8 +58,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
Future<void> toggleChapter(String id, int bookIndex, int chapter) async {
final String key = Bible.chapterKey(bookIndex, chapter);
final List<Tracker> current = state.value ?? const <Tracker>[];
final List<Tracker> next = current
final List<Tracker> next = state
.map((Tracker tracker) {
if (tracker.id != id) return tracker;
final Set<String> updated = <String>{...tracker.readChapters};
@ -99,8 +77,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
required bool read,
}) async {
final Book book = Bible.books[bookIndex];
final List<Tracker> current = state.value ?? const <Tracker>[];
final List<Tracker> next = current
final List<Tracker> next = state
.map((Tracker tracker) {
if (tracker.id != id) return tracker;
final Set<String> updated = <String>{...tracker.readChapters};
@ -119,8 +96,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
}
Future<void> reset(String id) async {
final List<Tracker> current = state.value ?? const <Tracker>[];
final List<Tracker> next = current
final List<Tracker> next = state
.map(
(Tracker tracker) => tracker.id == id
? tracker.copyWith(readChapters: const <String>{})
@ -131,18 +107,17 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
}
}
final StateNotifierProvider<TrackerService, AsyncValue<List<Tracker>>>
trackerServiceProvider =
StateNotifierProvider<TrackerService, AsyncValue<List<Tracker>>>(
(Ref ref) => TrackerService(ref.read(trackerStorageProvider)),
);
final StateNotifierProvider<TrackerService, List<Tracker>>
trackerServiceProvider = StateNotifierProvider<TrackerService, List<Tracker>>(
(Ref ref) => TrackerService(ref.read(trackerStorageProvider)),
);
/// Convenience selector for a single tracker by id.
final ProviderFamily<Tracker?, String> trackerByIdProvider =
Provider.family<Tracker?, String>((Ref ref, String id) {
final AsyncValue<List<Tracker>> state = ref.watch(trackerServiceProvider);
return state.value?.cast<Tracker?>().firstWhere(
(Tracker? t) => t?.id == id,
orElse: () => null,
);
final List<Tracker> trackers = ref.watch(trackerServiceProvider);
for (final Tracker tracker in trackers) {
if (tracker.id == id) return tracker;
}
return null;
});

View file

@ -17,7 +17,10 @@ class TrackerStorage {
final SharedPreferences _prefs;
Future<List<Tracker>> 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<Tracker> loadAll() {
final String? raw = _prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
return <Tracker>[];

View file

@ -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<Widget> _wrapped() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
Future<Widget> _pumpApp({
Map<String, Object>? initialPrefs,
LastLocation? pending,
}) async {
SharedPreferences.setMockInitialValues(initialPrefs ?? <String, Object>{});
final prefs = await SharedPreferences.getInstance();
return ProviderScope(
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
child: MaterialApp(theme: AppTheme.light(), home: const HomeScreen()),
overrides: <Override>[
sharedPreferencesProvider.overrideWithValue(prefs),
pendingRestoreProvider.overrideWithValue(pending),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const HomeScreen(),
navigatorObservers: <NavigatorObserver>[
LastLocationObserver(
(LastLocation? loc) => LastLocationService(prefs).save(loc),
),
],
),
);
}
String _seedTrackerJson(Tracker tracker) =>
jsonEncode(<Map<String, dynamic>>[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<ProviderScope>(
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: <String, Object>{
'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: <String, Object>{
'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(<String, Object>{});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: <Override>[
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: MaterialApp(
theme: AppTheme.light(),
home: const HomeScreen(),
navigatorObservers: <NavigatorObserver>[
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<NavigatorState>(
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);
});
}

View file

@ -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(<String, Object>{});
final prefs = await SharedPreferences.getInstance();
final service = LastLocationService(prefs);
expect(service.load(), isNull);
});
test('round-trips a location through SharedPreferences', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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(<String, Object>{
'bible_tracker.last_location.v1': 'not-json',
});
final prefs = await SharedPreferences.getInstance();
final service = LastLocationService(prefs);
expect(service.load(), isNull);
});
});
}

View file

@ -15,30 +15,18 @@ Future<ProviderContainer> _container() async {
);
}
Future<List<Tracker>> _waitForData(ProviderContainer container) async {
while (true) {
final AsyncValue<List<Tracker>> state = container.read(
trackerServiceProvider,
);
if (state.hasValue) return state.value!;
await Future<void>.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<Tracker> 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>[tracker]);
expect(container.read(trackerServiceProvider), <Tracker>[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, <String>{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<Tracker> current = container.read(trackerServiceProvider).value!;
final List<Tracker> 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<Tracker> current = container.read(trackerServiceProvider).value!;
final List<Tracker> current = container.read(trackerServiceProvider);
expect(current.length, 1);
expect(current.single.name, 'B');
});
@ -135,7 +115,6 @@ void main() {
final first = ProviderContainer(
overrides: <Override>[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: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
);
addTearDown(second.dispose);
final List<Tracker> loaded = await _waitForData(second);
final List<Tracker> 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());
});