track last open book
This commit is contained in:
parent
27338de5f6
commit
4c534089ae
11 changed files with 707 additions and 126 deletions
|
|
@ -3,23 +3,41 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
|
import 'services/last_location.dart';
|
||||||
import 'services/tracker_storage.dart';
|
import 'services/tracker_storage.dart';
|
||||||
import 'widgets/theme.dart';
|
import 'widgets/theme.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
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(
|
runApp(
|
||||||
ProviderScope(
|
ProviderScope(
|
||||||
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
|
overrides: <Override>[
|
||||||
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||||
|
pendingRestoreProvider.overrideWithValue(pending),
|
||||||
|
],
|
||||||
child: const BibleTrackerApp(),
|
child: const BibleTrackerApp(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class BibleTrackerApp extends StatelessWidget {
|
class BibleTrackerApp extends ConsumerStatefulWidget {
|
||||||
const BibleTrackerApp({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
|
@ -29,6 +47,7 @@ class BibleTrackerApp extends StatelessWidget {
|
||||||
darkTheme: AppTheme.dark(),
|
darkTheme: AppTheme.dark(),
|
||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
home: const HomeScreen(),
|
home: const HomeScreen(),
|
||||||
|
navigatorObservers: <NavigatorObserver>[_observer],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,77 @@
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../data/bible.dart';
|
||||||
import '../models/tracker.dart';
|
import '../models/tracker.dart';
|
||||||
|
import '../services/last_location.dart';
|
||||||
import '../services/tracker_service.dart';
|
import '../services/tracker_service.dart';
|
||||||
import '../widgets/theme.dart';
|
import '../widgets/theme.dart';
|
||||||
import '../widgets/tracker_tile.dart';
|
import '../widgets/tracker_tile.dart';
|
||||||
|
import 'book_screen.dart';
|
||||||
import 'create_tracker_screen.dart';
|
import 'create_tracker_screen.dart';
|
||||||
import 'tracker_screen.dart';
|
import 'tracker_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||||
final AsyncValue<List<Tracker>> state = ref.watch(trackerServiceProvider);
|
}
|
||||||
|
|
||||||
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('My Bible Tracker')),
|
appBar: AppBar(title: const Text('My Bible Tracker')),
|
||||||
body: state.when(
|
body: trackers.isEmpty
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
? const _EmptyState()
|
||||||
error: (Object error, StackTrace stack) =>
|
: ListView.separated(
|
||||||
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),
|
padding: const EdgeInsets.all(AppTokens.spaceL),
|
||||||
itemCount: trackers.length,
|
itemCount: trackers.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, _) =>
|
||||||
const SizedBox(height: AppTokens.spaceM),
|
const SizedBox(height: AppTokens.spaceM),
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
final Tracker tracker = trackers[index];
|
final Tracker tracker = trackers[index];
|
||||||
|
|
@ -35,14 +79,13 @@ class HomeScreen extends ConsumerWidget {
|
||||||
tracker: tracker,
|
tracker: tracker,
|
||||||
onTap: () => Navigator.of(context).push<void>(
|
onTap: () => Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(name: '/tracker/${tracker.id}'),
|
||||||
builder: (_) => TrackerScreen(trackerId: tracker.id),
|
builder: (_) => TrackerScreen(trackerId: tracker.id),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onLongPress: () => _showTrackerMenu(context, ref, tracker),
|
onLongPress: () => _showTrackerMenu(context, ref, tracker),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: () => Navigator.of(context).push<void>(
|
onPressed: () => Navigator.of(context).push<void>(
|
||||||
|
|
|
||||||
|
|
@ -248,6 +248,9 @@ class _BooksSliver extends ConsumerWidget {
|
||||||
accent: accent,
|
accent: accent,
|
||||||
onTap: () => Navigator.of(context).push<void>(
|
onTap: () => Navigator.of(context).push<void>(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
|
settings: RouteSettings(
|
||||||
|
name: '/tracker/${tracker.id}/book/${book.index}',
|
||||||
|
),
|
||||||
builder: (_) =>
|
builder: (_) =>
|
||||||
BookScreen(trackerId: tracker.id, bookIndex: book.index),
|
BookScreen(trackerId: tracker.id, bookIndex: book.index),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
135
lib/services/last_location.dart
Normal file
135
lib/services/last_location.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
188
lib/services/last_location.freezed.dart
Normal file
188
lib/services/last_location.freezed.dart
Normal 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;
|
||||||
|
}
|
||||||
19
lib/services/last_location.g.dart
Normal file
19
lib/services/last_location.g.dart
Normal 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,
|
||||||
|
};
|
||||||
|
|
@ -8,32 +8,18 @@ import 'tracker_storage.dart';
|
||||||
|
|
||||||
/// Manages the list of [Tracker]s and exposes them as state to the UI.
|
/// 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
|
/// Initial state is loaded synchronously from [TrackerStorage] so the first
|
||||||
/// simple read/write-all and there are no complex queries or caches.
|
/// frame already has the full list. Mutations persist asynchronously.
|
||||||
class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
class TrackerService extends StateNotifier<List<Tracker>> {
|
||||||
TrackerService(this._storage, {Uuid? uuid})
|
TrackerService(this._storage, {Uuid? uuid})
|
||||||
: _uuid = uuid ?? const Uuid(),
|
: _uuid = uuid ?? const Uuid(),
|
||||||
super(const AsyncValue<List<Tracker>>.loading()) {
|
super(_storage.loadAll());
|
||||||
unawaitedLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
final TrackerStorage _storage;
|
final TrackerStorage _storage;
|
||||||
final Uuid _uuid;
|
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 {
|
Future<void> _persist(List<Tracker> trackers) async {
|
||||||
state = AsyncValue<List<Tracker>>.data(trackers);
|
state = trackers;
|
||||||
await _storage.saveAll(trackers);
|
await _storage.saveAll(trackers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,27 +27,21 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
||||||
final Tracker tracker = Tracker(
|
final Tracker tracker = Tracker(
|
||||||
id: _uuid.v4(),
|
id: _uuid.v4(),
|
||||||
name: name,
|
name: name,
|
||||||
// ignore: deprecated_member_use
|
colorValue: color.toARGB32(),
|
||||||
colorValue: color.value,
|
|
||||||
startDate: DateTime.now(),
|
startDate: DateTime.now(),
|
||||||
);
|
);
|
||||||
final List<Tracker> next = <Tracker>[
|
final List<Tracker> next = <Tracker>[...state, tracker];
|
||||||
...state.value ?? const <Tracker>[],
|
|
||||||
tracker,
|
|
||||||
];
|
|
||||||
await _persist(next);
|
await _persist(next);
|
||||||
return tracker;
|
return tracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateMeta(String id, {String? name, Color? color}) async {
|
Future<void> updateMeta(String id, {String? name, Color? color}) async {
|
||||||
final List<Tracker> current = state.value ?? const <Tracker>[];
|
final List<Tracker> next = state
|
||||||
final List<Tracker> next = current
|
|
||||||
.map(
|
.map(
|
||||||
(Tracker tracker) => tracker.id == id
|
(Tracker tracker) => tracker.id == id
|
||||||
? tracker.copyWith(
|
? tracker.copyWith(
|
||||||
name: name ?? tracker.name,
|
name: name ?? tracker.name,
|
||||||
// ignore: deprecated_member_use
|
colorValue: color?.toARGB32() ?? tracker.colorValue,
|
||||||
colorValue: color?.value ?? tracker.colorValue,
|
|
||||||
)
|
)
|
||||||
: tracker,
|
: tracker,
|
||||||
)
|
)
|
||||||
|
|
@ -70,8 +50,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(String id) async {
|
Future<void> delete(String id) async {
|
||||||
final List<Tracker> current = state.value ?? const <Tracker>[];
|
final List<Tracker> next = state
|
||||||
final List<Tracker> next = current
|
|
||||||
.where((Tracker tracker) => tracker.id != id)
|
.where((Tracker tracker) => tracker.id != id)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _persist(next);
|
await _persist(next);
|
||||||
|
|
@ -79,8 +58,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
||||||
|
|
||||||
Future<void> toggleChapter(String id, int bookIndex, int chapter) async {
|
Future<void> toggleChapter(String id, int bookIndex, int chapter) async {
|
||||||
final String key = Bible.chapterKey(bookIndex, chapter);
|
final String key = Bible.chapterKey(bookIndex, chapter);
|
||||||
final List<Tracker> current = state.value ?? const <Tracker>[];
|
final List<Tracker> next = state
|
||||||
final List<Tracker> next = current
|
|
||||||
.map((Tracker tracker) {
|
.map((Tracker tracker) {
|
||||||
if (tracker.id != id) return tracker;
|
if (tracker.id != id) return tracker;
|
||||||
final Set<String> updated = <String>{...tracker.readChapters};
|
final Set<String> updated = <String>{...tracker.readChapters};
|
||||||
|
|
@ -99,8 +77,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
||||||
required bool read,
|
required bool read,
|
||||||
}) async {
|
}) async {
|
||||||
final Book book = Bible.books[bookIndex];
|
final Book book = Bible.books[bookIndex];
|
||||||
final List<Tracker> current = state.value ?? const <Tracker>[];
|
final List<Tracker> next = state
|
||||||
final List<Tracker> next = current
|
|
||||||
.map((Tracker tracker) {
|
.map((Tracker tracker) {
|
||||||
if (tracker.id != id) return tracker;
|
if (tracker.id != id) return tracker;
|
||||||
final Set<String> updated = <String>{...tracker.readChapters};
|
final Set<String> updated = <String>{...tracker.readChapters};
|
||||||
|
|
@ -119,8 +96,7 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reset(String id) async {
|
Future<void> reset(String id) async {
|
||||||
final List<Tracker> current = state.value ?? const <Tracker>[];
|
final List<Tracker> next = state
|
||||||
final List<Tracker> next = current
|
|
||||||
.map(
|
.map(
|
||||||
(Tracker tracker) => tracker.id == id
|
(Tracker tracker) => tracker.id == id
|
||||||
? tracker.copyWith(readChapters: const <String>{})
|
? tracker.copyWith(readChapters: const <String>{})
|
||||||
|
|
@ -131,18 +107,17 @@ class TrackerService extends StateNotifier<AsyncValue<List<Tracker>>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final StateNotifierProvider<TrackerService, AsyncValue<List<Tracker>>>
|
final StateNotifierProvider<TrackerService, List<Tracker>>
|
||||||
trackerServiceProvider =
|
trackerServiceProvider = StateNotifierProvider<TrackerService, List<Tracker>>(
|
||||||
StateNotifierProvider<TrackerService, AsyncValue<List<Tracker>>>(
|
|
||||||
(Ref ref) => TrackerService(ref.read(trackerStorageProvider)),
|
(Ref ref) => TrackerService(ref.read(trackerStorageProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Convenience selector for a single tracker by id.
|
/// Convenience selector for a single tracker by id.
|
||||||
final ProviderFamily<Tracker?, String> trackerByIdProvider =
|
final ProviderFamily<Tracker?, String> trackerByIdProvider =
|
||||||
Provider.family<Tracker?, String>((Ref ref, String id) {
|
Provider.family<Tracker?, String>((Ref ref, String id) {
|
||||||
final AsyncValue<List<Tracker>> state = ref.watch(trackerServiceProvider);
|
final List<Tracker> trackers = ref.watch(trackerServiceProvider);
|
||||||
return state.value?.cast<Tracker?>().firstWhere(
|
for (final Tracker tracker in trackers) {
|
||||||
(Tracker? t) => t?.id == id,
|
if (tracker.id == id) return tracker;
|
||||||
orElse: () => null,
|
}
|
||||||
);
|
return null;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,10 @@ class TrackerStorage {
|
||||||
|
|
||||||
final SharedPreferences _prefs;
|
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);
|
final String? raw = _prefs.getString(_storageKey);
|
||||||
if (raw == null || raw.isEmpty) {
|
if (raw == null || raw.isEmpty) {
|
||||||
return <Tracker>[];
|
return <Tracker>[];
|
||||||
|
|
|
||||||
|
|
@ -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/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_service.dart';
|
||||||
import 'package:bible_tracker/services/tracker_storage.dart';
|
import 'package:bible_tracker/services/tracker_storage.dart';
|
||||||
import 'package:bible_tracker/widgets/theme.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:flutter_test/flutter_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
Future<Widget> _wrapped() async {
|
Future<Widget> _pumpApp({
|
||||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
Map<String, Object>? initialPrefs,
|
||||||
|
LastLocation? pending,
|
||||||
|
}) async {
|
||||||
|
SharedPreferences.setMockInitialValues(initialPrefs ?? <String, Object>{});
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
|
overrides: <Override>[
|
||||||
child: MaterialApp(theme: AppTheme.light(), home: const HomeScreen()),
|
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() {
|
void main() {
|
||||||
testWidgets('shows empty state when no trackers exist', (tester) async {
|
testWidgets('shows empty state when no trackers exist', (tester) async {
|
||||||
await tester.pumpWidget(await _wrapped());
|
await tester.pumpWidget(await _pumpApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Start your first tracker'), findsOneWidget);
|
expect(find.text('Start your first tracker'), findsOneWidget);
|
||||||
expect(find.text('New Tracker'), findsOneWidget);
|
expect(find.text('New Tracker'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('creating a tracker shows it on the list', (tester) async {
|
testWidgets('creating a tracker shows it on the list', (tester) async {
|
||||||
await tester.pumpWidget(await _wrapped());
|
await tester.pumpWidget(await _pumpApp());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final ProviderScope scope = tester.widget<ProviderScope>(
|
|
||||||
find.byType(ProviderScope),
|
|
||||||
);
|
|
||||||
expect(scope, isNotNull);
|
|
||||||
|
|
||||||
final BuildContext context = tester.element(find.byType(HomeScreen));
|
final BuildContext context = tester.element(find.byType(HomeScreen));
|
||||||
final ProviderContainer container = ProviderScope.containerOf(context);
|
final ProviderContainer container = ProviderScope.containerOf(context);
|
||||||
await container
|
await container
|
||||||
|
|
@ -43,4 +62,138 @@ void main() {
|
||||||
expect(find.text('My Plan'), findsOneWidget);
|
expect(find.text('My Plan'), findsOneWidget);
|
||||||
expect(find.text('0%'), 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
test/services/last_location_test.dart
Normal file
68
test/services/last_location_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
test('starts empty after initial load', () async {
|
test('starts empty when nothing is persisted', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
final List<Tracker> data = await _waitForData(container);
|
expect(container.read(trackerServiceProvider), isEmpty);
|
||||||
expect(data, isEmpty);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create adds a tracker with the given name and color', () async {
|
test('create adds a tracker with the given name and color', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
await _waitForData(container);
|
|
||||||
|
|
||||||
final tracker = await container
|
final tracker = await container
|
||||||
.read(trackerServiceProvider.notifier)
|
.read(trackerServiceProvider.notifier)
|
||||||
|
|
@ -46,14 +34,12 @@ void main() {
|
||||||
|
|
||||||
expect(tracker.name, 'My Plan');
|
expect(tracker.name, 'My Plan');
|
||||||
expect(tracker.colorValue, const Color(0xFF123456).toARGB32());
|
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 {
|
test('toggleChapter adds and then removes chapter', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
await _waitForData(container);
|
|
||||||
|
|
||||||
final service = container.read(trackerServiceProvider.notifier);
|
final service = container.read(trackerServiceProvider.notifier);
|
||||||
final tracker = await service.create(
|
final tracker = await service.create(
|
||||||
name: 'Plan',
|
name: 'Plan',
|
||||||
|
|
@ -61,18 +47,17 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.toggleChapter(tracker.id, 0, 1);
|
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)});
|
expect(current.readChapters, <String>{Bible.chapterKey(0, 1)});
|
||||||
|
|
||||||
await service.toggleChapter(tracker.id, 0, 1);
|
await service.toggleChapter(tracker.id, 0, 1);
|
||||||
current = container.read(trackerServiceProvider).value!.single;
|
current = container.read(trackerServiceProvider).single;
|
||||||
expect(current.readChapters, isEmpty);
|
expect(current.readChapters, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setBookRead marks all chapters of the book', () async {
|
test('setBookRead marks all chapters of the book', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
await _waitForData(container);
|
|
||||||
final service = container.read(trackerServiceProvider.notifier);
|
final service = container.read(trackerServiceProvider.notifier);
|
||||||
final tracker = await service.create(
|
final tracker = await service.create(
|
||||||
name: 'Plan',
|
name: 'Plan',
|
||||||
|
|
@ -80,10 +65,7 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.setBookRead(tracker.id, 0, read: true);
|
await service.setBookRead(tracker.id, 0, read: true);
|
||||||
final Tracker current = container
|
final Tracker current = container.read(trackerServiceProvider).single;
|
||||||
.read(trackerServiceProvider)
|
|
||||||
.value!
|
|
||||||
.single;
|
|
||||||
final Book genesis = Bible.books[0];
|
final Book genesis = Bible.books[0];
|
||||||
expect(current.readChapters.length, genesis.chapterCount);
|
expect(current.readChapters.length, genesis.chapterCount);
|
||||||
for (int chapter = 1; chapter <= genesis.chapterCount; chapter++) {
|
for (int chapter = 1; chapter <= genesis.chapterCount; chapter++) {
|
||||||
|
|
@ -94,7 +76,6 @@ void main() {
|
||||||
test('reset clears only the target tracker', () async {
|
test('reset clears only the target tracker', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
await _waitForData(container);
|
|
||||||
final service = container.read(trackerServiceProvider.notifier);
|
final service = container.read(trackerServiceProvider.notifier);
|
||||||
final a = await service.create(name: 'A', color: const Color(0xFF111111));
|
final a = await service.create(name: 'A', color: const Color(0xFF111111));
|
||||||
final b = await service.create(name: 'B', color: const Color(0xFF222222));
|
final b = await service.create(name: 'B', color: const Color(0xFF222222));
|
||||||
|
|
@ -103,7 +84,7 @@ void main() {
|
||||||
|
|
||||||
await service.reset(a.id);
|
await service.reset(a.id);
|
||||||
|
|
||||||
final List<Tracker> current = container.read(trackerServiceProvider).value!;
|
final List<Tracker> current = container.read(trackerServiceProvider);
|
||||||
expect(
|
expect(
|
||||||
current.firstWhere((Tracker t) => t.id == a.id).readChapters,
|
current.firstWhere((Tracker t) => t.id == a.id).readChapters,
|
||||||
isEmpty,
|
isEmpty,
|
||||||
|
|
@ -117,13 +98,12 @@ void main() {
|
||||||
test('delete removes the tracker', () async {
|
test('delete removes the tracker', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
await _waitForData(container);
|
|
||||||
final service = container.read(trackerServiceProvider.notifier);
|
final service = container.read(trackerServiceProvider.notifier);
|
||||||
final a = await service.create(name: 'A', color: const Color(0xFF111111));
|
final a = await service.create(name: 'A', color: const Color(0xFF111111));
|
||||||
await service.create(name: 'B', color: const Color(0xFF222222));
|
await service.create(name: 'B', color: const Color(0xFF222222));
|
||||||
|
|
||||||
await service.delete(a.id);
|
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.length, 1);
|
||||||
expect(current.single.name, 'B');
|
expect(current.single.name, 'B');
|
||||||
});
|
});
|
||||||
|
|
@ -135,7 +115,6 @@ void main() {
|
||||||
final first = ProviderContainer(
|
final first = ProviderContainer(
|
||||||
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
|
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
|
||||||
);
|
);
|
||||||
await _waitForData(first);
|
|
||||||
final created = await first
|
final created = await first
|
||||||
.read(trackerServiceProvider.notifier)
|
.read(trackerServiceProvider.notifier)
|
||||||
.create(name: 'Persisted', color: const Color(0xFFAABBCC));
|
.create(name: 'Persisted', color: const Color(0xFFAABBCC));
|
||||||
|
|
@ -148,7 +127,7 @@ void main() {
|
||||||
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
|
overrides: <Override>[sharedPreferencesProvider.overrideWithValue(prefs)],
|
||||||
);
|
);
|
||||||
addTearDown(second.dispose);
|
addTearDown(second.dispose);
|
||||||
final List<Tracker> loaded = await _waitForData(second);
|
final List<Tracker> loaded = second.read(trackerServiceProvider);
|
||||||
|
|
||||||
expect(loaded.length, 1);
|
expect(loaded.length, 1);
|
||||||
expect(loaded.single.name, 'Persisted');
|
expect(loaded.single.name, 'Persisted');
|
||||||
|
|
@ -158,7 +137,6 @@ void main() {
|
||||||
test('updateMeta changes name and color', () async {
|
test('updateMeta changes name and color', () async {
|
||||||
final container = await _container();
|
final container = await _container();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
await _waitForData(container);
|
|
||||||
final service = container.read(trackerServiceProvider.notifier);
|
final service = container.read(trackerServiceProvider.notifier);
|
||||||
final tracker = await service.create(
|
final tracker = await service.create(
|
||||||
name: 'Old',
|
name: 'Old',
|
||||||
|
|
@ -170,10 +148,7 @@ void main() {
|
||||||
name: 'New',
|
name: 'New',
|
||||||
color: const Color(0xFF222222),
|
color: const Color(0xFF222222),
|
||||||
);
|
);
|
||||||
final Tracker current = container
|
final Tracker current = container.read(trackerServiceProvider).single;
|
||||||
.read(trackerServiceProvider)
|
|
||||||
.value!
|
|
||||||
.single;
|
|
||||||
expect(current.name, 'New');
|
expect(current.name, 'New');
|
||||||
expect(current.colorValue, const Color(0xFF222222).toARGB32());
|
expect(current.colorValue, const Color(0xFF222222).toARGB32());
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue