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 '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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
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.
|
||||
///
|
||||
/// 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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>[];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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() {
|
||||
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());
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue