From a5c5e8b2375d014e9f7680f1957a2c1050287210 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Tue, 6 Oct 2026 17:31:03 +0300 Subject: [PATCH 1/4] feat: redesign goals around durable reading activity --- .../reading_tracking_test.dart | 58 ++ .../support/tracking_app.dart | 154 ++++ app/lib/data/data_store.dart | 225 ++++- .../data/repositories/library_repository.dart | 9 + .../repositories/tracking_repository.dart | 23 + app/lib/goals/goal_calendar.dart | 91 ++ app/lib/goals/goal_progress.dart | 187 +++++ app/lib/main.dart | 18 + app/lib/models/goal_period_record.dart | 15 + app/lib/models/reading_activity.dart | 170 ++++ app/lib/models/reading_goal.dart | 332 ++++---- app/lib/models/reading_session.dart | 3 + app/lib/pages/goals_page.dart | 689 +++++++++------- app/lib/pages/reader_page.dart | 146 +++- app/lib/powersync/library_database.dart | 133 ++- app/lib/powersync/library_row_mapper.dart | 5 +- .../papyrus_powersync_connector.dart | 26 +- app/lib/powersync/papyrus_schema.dart | 20 + app/lib/powersync/powersync_service.dart | 34 +- app/lib/providers/book_details_provider.dart | 4 +- app/lib/providers/dashboard_provider.dart | 39 +- app/lib/providers/goals_provider.dart | 443 ++++++---- app/lib/providers/statistics_provider.dart | 191 +++-- app/lib/reader/reader_book_adapter.dart | 8 +- app/lib/reader/reader_session.dart | 20 +- app/lib/reader/reading_activity_tracker.dart | 177 ++++ app/lib/services/reading_device_identity.dart | 19 + .../goals/active_goal_details_sheet.dart | 555 ------------- app/lib/widgets/goals/add_goal_sheet.dart | 774 ++++++++---------- .../widgets/goals/completed_goal_chip.dart | 481 ----------- app/lib/widgets/goals/goal_card.dart | 393 ++++----- app/lib/widgets/goals/goal_details_sheet.dart | 174 ++++ app/lib/widgets/goals/log_reading_sheet.dart | 403 +++++++++ .../flutter/generated_plugin_registrant.cc | 4 + app/linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + app/pubspec.lock | 26 +- app/pubspec.yaml | 5 +- app/test/goals/fixtures/projections.json | 562 +++++++++++++ app/test/goals/goal_progress_test.dart | 67 ++ app/test/goals/goals_provider_test.dart | 67 ++ app/test/pages/goals_page_test.dart | 66 +- .../powersync/papyrus_schema_mode_test.dart | 7 +- .../powersync/tracking_persistence_test.dart | 180 ++++ .../providers/book_details_provider_test.dart | 4 +- .../reader/reading_activity_tracker_test.dart | 157 ++++ .../goals/goal_details_sheets_test.dart | 93 +-- .../shared/bottom_sheet_expansion_test.dart | 15 +- .../shared/bottom_sheet_layouts_test.dart | 9 +- app/test_driver/reading_tracking_driver.dart | 3 + app/tool/preview_goals.dart | 43 + .../flutter/generated_plugin_registrant.cc | 3 + app/windows/flutter/generated_plugins.cmake | 1 + docs/goals-tracking.md | 89 ++ docs/images/goals-desktop.png | Bin 0 -> 73566 bytes docs/images/goals-mobile.png | Bin 0 -> 42658 bytes 56 files changed, 4886 insertions(+), 2537 deletions(-) create mode 100644 app/integration_test/reading_tracking_test.dart create mode 100644 app/integration_test/support/tracking_app.dart create mode 100644 app/lib/data/repositories/tracking_repository.dart create mode 100644 app/lib/goals/goal_calendar.dart create mode 100644 app/lib/goals/goal_progress.dart create mode 100644 app/lib/models/goal_period_record.dart create mode 100644 app/lib/models/reading_activity.dart create mode 100644 app/lib/reader/reading_activity_tracker.dart create mode 100644 app/lib/services/reading_device_identity.dart delete mode 100644 app/lib/widgets/goals/active_goal_details_sheet.dart delete mode 100644 app/lib/widgets/goals/completed_goal_chip.dart create mode 100644 app/lib/widgets/goals/goal_details_sheet.dart create mode 100644 app/lib/widgets/goals/log_reading_sheet.dart create mode 100644 app/test/goals/fixtures/projections.json create mode 100644 app/test/goals/goal_progress_test.dart create mode 100644 app/test/goals/goals_provider_test.dart create mode 100644 app/test/powersync/tracking_persistence_test.dart create mode 100644 app/test/reader/reading_activity_tracker_test.dart create mode 100644 app/test_driver/reading_tracking_driver.dart create mode 100644 app/tool/preview_goals.dart create mode 100644 docs/goals-tracking.md create mode 100644 docs/images/goals-desktop.png create mode 100644 docs/images/goals-mobile.png diff --git a/app/integration_test/reading_tracking_test.dart b/app/integration_test/reading_tracking_test.dart new file mode 100644 index 00000000..2cc3c8a0 --- /dev/null +++ b/app/integration_test/reading_tracking_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:papyrus/pages/goals_page.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'support/tracking_app.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('real EPUB/PDF reader activity reaches Goals and survives closing', (tester) async { + final app = TrackingValidationApp(); + await app.initialize(); + await tester.pumpWidget(app.build()); + await tester.pumpAndSettle(); + expect(find.text('New goal'), findsOneWidget); + for (final format in ['epub', 'pdf']) { + await tester.tap(find.text('Open $format')); + for (var i = 0; i < 100; i++) { + await tester.pump(const Duration(milliseconds: 200)); + if (find.byType(PapyrusReader).evaluate().isNotEmpty && find.byTooltip('Back').evaluate().isNotEmpty) break; + } + expect(find.byType(PapyrusReader), findsOneWidget); + // Pump real frames while asynchronous parsing/layout reaches ready content. + // EPUB cover-only chapters have no text coverage, so turn to readable content. + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(seconds: 1)); + if (format == 'epub' && i >= 3 && i <= 5 && find.byTooltip('Next').evaluate().isNotEmpty) { + await tester.tap(find.byTooltip('Next')); + } + if (app.store.readingActivities.any((a) => a.bookId == format && a.coverage.isNotEmpty)) break; + } + if (find.text('Not yet').evaluate().isNotEmpty) { + await tester.tap(find.text('Not yet')); + await tester.pump(const Duration(seconds: 3)); + } + await tester.tap(find.byTooltip('Back').first); + await tester.pumpAndSettle(); + expect(find.byType(GoalsPage), findsOneWidget); + final activity = app.store.effectiveReadingActivities + .where((a) => a.bookId == format && a.kind == 'reading') + .toList(); + expect(activity, isNotEmpty); + expect(activity.fold(0, (n, a) => n + a.seconds), greaterThanOrEqualTo(10)); + expect(activity.expand((a) => a.coverage), isNotEmpty); + } + final totals = app.goals.current; + expect(totals.firstWhere((p) => p.goal.type == GoalType.minutes).seconds, greaterThanOrEqualTo(20)); + binding.reportData = { + 'activity_count': app.store.readingActivities.length, + 'seconds': totals.first.seconds, + 'estimated_pages': totals.first.pages, + 'sources': app.store.readingActivities.map((a) => a.source).toSet().toList(), + }; + await tester.pumpWidget(const SizedBox.shrink()); + await app.close(); + }); +} diff --git a/app/integration_test/support/tracking_app.dart b/app/integration_test/support/tracking_app.dart new file mode 100644 index 00000000..a27b1765 --- /dev/null +++ b/app/integration_test/support/tracking_app.dart @@ -0,0 +1,154 @@ +import 'package:papyrus/services/reading_device_identity.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/pages/goals_page.dart'; +import 'package:papyrus/pages/reader_page.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/providers/preferences_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:powersync/powersync.dart' hide Column; + +class NoNetworkConnector extends PowerSyncBackendConnector { + @override + Future fetchCredentials() async => null; + @override + Future uploadData(PowerSyncDatabase database) async {} +} + +class FixtureMediaCache extends MediaCacheService { + FixtureMediaCache(this.bytes); + final Map bytes; + @override + Future ensureBookFileCached( + Book book, { + required LocalBookFileReader readLocalBookFile, + required LocalBookFileWriter writeLocalBookFile, + required MediaDownloader downloadMedia, + }) async => bytes[book.id]!; +} + +class FixtureAuth extends ChangeNotifier implements AuthProvider { + @override + Future downloadMedia(String assetId) async => Uint8List(0); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class TrackingValidationApp { + late final PapyrusPowerSyncService database; + late final DataStore store; + late final GoalsProvider goals; + late final PreferencesProvider preferences; + late final GoRouter router; + final bytes = {}; + final fixtureAuth = FixtureAuth(); + Future initialize() async { + const origin = String.fromEnvironment('TRACKING_FIXTURE_ORIGIN', defaultValue: 'http://127.0.0.1:7311'); + for (final format in ['epub', 'pdf']) { + final response = await http.get(Uri.parse('$origin/reader.$format')); + if (response.statusCode != 200) throw StateError('Fixture $format could not load'); + bytes[format] = response.bodyBytes; + } + final suffix = DateTime.now().microsecondsSinceEpoch; + final root = kIsWeb ? '' : (await getApplicationSupportDirectory()).path; + database = PapyrusPowerSyncService( + connectorFactory: NoNetworkConnector.new, + connectAuthenticated: false, + pathResolver: (_, _, _) async => '${root.isEmpty ? '' : '$root/'}goals-validation-$suffix.db', + ); + await database.activateGuest(); + for (final format in [BookFormat.epub, BookFormat.pdf]) { + await database.upsert( + Book( + id: format.name, + title: format == BookFormat.epub + ? 'Alice’s Adventures in Wonderland' + : 'Structure and Interpretation of Computer Programs', + author: '', + addedAt: DateTime.now().toUtc(), + fileFormat: format, + pageCount: 200, + ), + ); + } + store = DataStore(bookRepository: database); + await store.waitUntilLoaded(); + goals = GoalsProvider(watchClock: false)..attach(store); + await goals.createGoal(type: GoalType.minutes, target: 30, period: GoalPeriod.daily, timezone: 'Europe/Vilnius'); + await goals.createGoal(type: GoalType.days, target: 5, period: GoalPeriod.weekly, timezone: 'Europe/Vilnius'); + await goals.createGoal(type: GoalType.pages, target: 100, period: GoalPeriod.weekly, timezone: 'Europe/Vilnius'); + final prefs = await SharedPreferences.getInstance(); + await ReadingDeviceIdentity.initialize(prefs); + preferences = PreferencesProvider(prefs); + router = GoRouter( + routes: [ + GoRoute( + path: '/', + name: 'BOOKS', + builder: (_, _) => Scaffold( + body: Column( + children: [ + Wrap( + children: [ + for (final format in ['epub', 'pdf']) + TextButton( + onPressed: () => router.goNamed('BOOK_READER', pathParameters: {'bookId': format}), + child: Text('Open $format'), + ), + ], + ), + const Expanded(child: GoalsPage()), + ], + ), + ), + ), + GoRoute(path: '/book/:bookId', name: 'BOOK_DETAILS', redirect: (_, _) => '/'), + GoRoute( + path: '/reader/:bookId', + name: 'BOOK_READER', + builder: (_, state) => ReaderPage(bookId: state.pathParameters['bookId']!), + ), + ], + ); + } + + Widget build({ThemeData? theme, double textScale = 1}) => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: preferences), + Provider.value(value: FixtureMediaCache(bytes)), + Provider(create: (_) => BookImportService()), + ChangeNotifierProvider.value(value: fixtureAuth), + ], + child: MaterialApp.router( + theme: theme ?? AppTheme.dark, + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + ), + ); + Future close() async { + goals.dispose(); + router.dispose(); + store.dispose(); + preferences.dispose(); + fixtureAuth.dispose(); + await database.clearGuestLibrary(); + await database.close(); + } +} diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 296ca637..e74a29d4 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -1,4 +1,12 @@ +import 'package:papyrus/services/reading_device_identity.dart'; import 'dart:async'; +import 'package:uuid/uuid.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/goal_period_record.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:flutter/foundation.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; @@ -34,12 +42,22 @@ class DataStore extends ChangeNotifier { final Map _bookmarks = {}; final Map _readingSessions = {}; final Map _readingGoals = {}; + final Map _activities = {}; + final Map _goalPeriods = {}; + TrackingRepository? get trackingRepository => _bookRepository is TrackingRepositoryOwner + ? (_bookRepository as TrackingRepositoryOwner).trackingRepository + : null; + List get readingActivities => _activities.values.toList(); + List get effectiveReadingActivities => effectiveActivities(_activities.values); + List get goalPeriods => _goalPeriods.values.toList(); + List get goalDefinitions => _readingGoals.values.toList(); // Junction table data final List _bookShelfRelations = []; final List _bookTagRelations = []; bool _isLoaded = false; + bool _disposed = false; BookRepository? _bookRepository; StreamSubscription>? _bookSubscription; StreamSubscription? _librarySubscription; @@ -62,6 +80,7 @@ class DataStore extends ChangeNotifier { } void _replaceLibrary(LibrarySnapshot snapshot) { + if (_disposed) return; _shelves ..clear() ..addEntries(snapshot.shelves.map((value) => MapEntry(value.id, value))); @@ -83,10 +102,16 @@ class DataStore extends ChangeNotifier { _bookTagRelations ..clear() ..addAll(snapshot.bookTags); - if (snapshot.books.isEmpty) { - _series.clear(); - _readingGoals.clear(); - } + _readingGoals + ..clear() + ..addEntries(snapshot.goals.map((value) => MapEntry(value.id, value))); + _activities + ..clear() + ..addEntries(snapshot.activities.map((value) => MapEntry(value.id, value))); + _goalPeriods + ..clear() + ..addEntries(snapshot.goalPeriods.map((value) => MapEntry(value.id, value))); + if (snapshot.books.isEmpty) _series.clear(); replaceBooksFromSync(snapshot.books); } @@ -133,8 +158,11 @@ class DataStore extends ChangeNotifier { List get annotations => _annotations.values.toList(); List get notes => _notes.values.toList(); List get bookmarks => _bookmarks.values.toList(); - List get readingSessions => _readingSessions.values.toList(); - List get readingGoals => _readingGoals.values.toList(); + List get readingSessions => + _activities.isEmpty ? _readingSessions.values.toList() : _activitySessions(); + List get readingGoals => _readingGoals.values + .map((goal) => projectGoal(goal, readingActivities, DateTime.now().toUtc()).projected) + .toList(); List get bookShelfRelations => List.unmodifiable(_bookShelfRelations); List get bookTagRelations => List.unmodifiable(_bookTagRelations); @@ -171,7 +199,8 @@ class DataStore extends ChangeNotifier { } bool isBookRepositoryCurrent(BookRepository repository) => - repository is EditableBookRepository ? repository.isCurrent : identical(_bookRepository, repository); + !_disposed && + (repository is EditableBookRepository ? repository.isCurrent : identical(_bookRepository, repository)); void addBook(Book book) { final repository = requireBookRepository(); @@ -198,11 +227,7 @@ class DataStore extends ChangeNotifier { final previous = _books[book.id]; _books[book.id] = book; notifyListeners(); - _reportWrite( - repository is EditableBookRepository && previous != null - ? repository.update(book, previous: previous) - : repository.upsert(book), - ); + _reportWrite(updateBookAndWait(book, previous: previous, repository: repository)); } void _reportWrite(Future operation) { @@ -213,9 +238,20 @@ class DataStore extends ChangeNotifier { ); } - Future updateBookAndWait(Book book, {Book? previous, BookRepository? repository}) async { + Future updateBookAndWait( + Book book, { + Book? previous, + BookRepository? repository, + String completionSource = 'manual', + String? completionDeviceId, + }) async { final target = repository ?? requireBookRepository(); final baseline = previous ?? _books[book.id]; + if (trackingRepository != null && isBookRepositoryCurrent(target)) { + final changes = completionChanges(book, baseline, source: completionSource, deviceId: completionDeviceId); + await commitTracking(book: book, previousBook: baseline, activities: changes); + return; + } if (target is EditableBookRepository && baseline != null) { await target.update(book, previous: baseline); final saved = await target.getById(book.id); @@ -285,6 +321,7 @@ class DataStore extends ChangeNotifier { } void replaceBooksFromSync(List books) { + if (_disposed) return; final mergedBooks = books .map((book) { final localBook = _books[book.id]; @@ -306,7 +343,7 @@ class DataStore extends ChangeNotifier { _annotations.removeWhere((key, annotation) => !syncedIds.contains(annotation.bookId)); _notes.removeWhere((key, note) => !syncedIds.contains(note.bookId)); _bookmarks.removeWhere((key, bookmark) => !syncedIds.contains(bookmark.bookId)); - _readingSessions.removeWhere((key, session) => !syncedIds.contains(session.bookId)); + _isLoaded = true; notifyListeners(); } @@ -579,26 +616,156 @@ class DataStore extends ChangeNotifier { ReadingGoal? getReadingGoal(String id) => _readingGoals[id]; List get activeGoals { - return _readingGoals.values.where((g) => g.isActive && !g.isArchived).toList(); + return readingGoals.where((g) => g.isActive && !g.isArchived && g.endDate.isAfter(DateTime.now().toUtc())).toList(); } List get completedGoals { - return _readingGoals.values.where((g) => g.isArchived).toList(); + return readingGoals.where((g) => g.isArchived).toList(); } - void addReadingGoal(ReadingGoal goal) { - _readingGoals[goal.id] = goal; + Future addReadingGoal(ReadingGoal goal) => commitTracking(goals: [goal]); + Future updateReadingGoal(ReadingGoal goal) => commitTracking(goals: [goal]); + Future deleteReadingGoal(String id) => commitTracking(deleteGoalId: id); + + Future commitTracking({ + List goals = const [], + List activities = const [], + List periods = const [], + String? deleteGoalId, + Book? book, + Book? previousBook, + TrackingRepository? repository, + }) async { + final target = repository ?? trackingRepository; + final scope = requireBookRepository(); + if (target != null) { + await target.commitTracking( + goals: goals, + activities: activities, + periods: periods, + deleteGoalId: deleteGoalId, + book: book, + previousBook: previousBook, + ); + if (!target.isCurrent || !isBookRepositoryCurrent(scope)) return; + } else if (book != null) { + await scope.upsert(book); + if (!isBookRepositoryCurrent(scope)) return; + } + for (final goal in goals) { + _readingGoals[goal.id] = goal; + } + for (final activity in activities) { + _activities[activity.id] = activity; + } + for (final period in periods) { + _goalPeriods[period.id] = period; + } + if (deleteGoalId != null) _readingGoals.remove(deleteGoalId); + if (book != null) _books[book.id] = book; notifyListeners(); } - void updateReadingGoal(ReadingGoal goal) { - _readingGoals[goal.id] = goal; - notifyListeners(); + List completionChanges( + Book book, + Book? previous, { + DateTime? at, + String source = 'manual', + String? deviceId, + }) { + final now = (at ?? DateTime.now()).toUtc(); + if (previous?.readingStatus != LibraryReadingStatus.completed && + book.readingStatus == LibraryReadingStatus.completed) { + return [ + ReadingActivity( + id: const Uuid().v4(), + bookId: book.id, + bookTitle: book.title, + startTime: now, + endTime: now, + createdAt: DateTime.now().toUtc(), + kind: 'completion', + source: source, + deviceId: deviceId ?? ReadingDeviceIdentity.current, + shelfIds: getShelfIdsForBook(book.id), + ), + ]; + } + if (previous?.readingStatus == LibraryReadingStatus.completed && + book.readingStatus != LibraryReadingStatus.completed) { + return effectiveReadingActivities + .where((activity) => activity.bookId == book.id && activity.kind == 'completion') + .map((original) => reversalFor(original, at: now)) + .toList(); + } + return []; + } + + ReadingActivity reversalFor(ReadingActivity original, {DateTime? at, String? note}) { + final now = (at ?? DateTime.now()).toUtc(); + return ReadingActivity( + id: const Uuid().v4(), + bookId: original.bookId, + bookTitle: original.bookTitle, + startTime: now, + endTime: now, + createdAt: now, + kind: 'reversal', + deviceId: ReadingDeviceIdentity.current, + correctionOf: original.id, + shelfIds: original.shelfIds, + note: note, + ); } - void deleteReadingGoal(String id) { - _readingGoals.remove(id); - notifyListeners(); + GoalProgress activityTotals(DateTime start, DateTime end, {String? timezone, String? bookId}) => projectGoal( + ReadingGoal( + id: 'projection', + type: GoalType.minutes, + targetValue: 1, + period: GoalPeriod.custom, + startDate: start.toUtc(), + endDate: end.toUtc(), + createdAt: start.toUtc(), + timezone: timezone ?? GoalCalendar.systemTimezone, + isRecurring: false, + scope: bookId == null ? GoalScope.library : GoalScope.book, + scopeId: bookId, + ), + readingActivities, + end.toUtc(), + ); + + List _activitySessions() { + final groups = >{}; + for (final activity in effectiveReadingActivities.where((a) => a.kind == 'reading')) { + var day = DateTime( + activity.startTime.toLocal().year, + activity.startTime.toLocal().month, + activity.startTime.toLocal().day, + ); + final last = activity.endTime.toLocal(); + do { + groups.putIfAbsent('${activity.bookId}:${day.toIso8601String()}', () => []).add(activity); + day = DateTime(day.year, day.month, day.day + 1); + } while (day.isBefore(last)); + } + return groups.entries.map((entry) { + final first = entry.value.first; + final day = DateTime.parse(entry.key.substring(first.bookId.length + 1)); + final end = DateTime(day.year, day.month, day.day + 1); + final totals = activityTotals(day, end, bookId: first.bookId); + return ReadingSession( + id: entry.key, + bookId: first.bookId, + startTime: day, + endTime: end, + startPosition: 0, + pagesRead: totals.pages.floor(), + recordedSeconds: totals.seconds, + createdAt: first.createdAt, + ); + }).toList(); } // ============================================================ @@ -803,9 +970,19 @@ class DataStore extends ChangeNotifier { _bookmarks.clear(); _readingSessions.clear(); _readingGoals.clear(); + _activities.clear(); + _goalPeriods.clear(); _bookShelfRelations.clear(); _bookTagRelations.clear(); _isLoaded = false; notifyListeners(); } + + @override + void dispose() { + _disposed = true; + unawaited(_bookSubscription?.cancel()); + unawaited(_librarySubscription?.cancel()); + super.dispose(); + } } diff --git a/app/lib/data/repositories/library_repository.dart b/app/lib/data/repositories/library_repository.dart index f7d6753b..7a2d8a0f 100644 --- a/app/lib/data/repositories/library_repository.dart +++ b/app/lib/data/repositories/library_repository.dart @@ -7,6 +7,9 @@ import 'package:papyrus/models/book_tag_relation.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/models/tag.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/goal_period_record.dart'; abstract interface class EntityRepository { Future getById(String id); @@ -44,6 +47,9 @@ abstract interface class LibraryMembershipWriter { } class LibrarySnapshot { + final List goals; + final List activities; + final List goalPeriods; final List books; final List shelves; final List tags; @@ -54,6 +60,9 @@ class LibrarySnapshot { final List bookTags; const LibrarySnapshot({ + this.goals = const [], + this.activities = const [], + this.goalPeriods = const [], this.books = const [], this.shelves = const [], this.tags = const [], diff --git a/app/lib/data/repositories/tracking_repository.dart b/app/lib/data/repositories/tracking_repository.dart new file mode 100644 index 00000000..840a48ef --- /dev/null +++ b/app/lib/data/repositories/tracking_repository.dart @@ -0,0 +1,23 @@ +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/goal_period_record.dart'; + +/// Capture this handle when opening a reader or sheet; never follow profile switches. +abstract interface class TrackingRepository { + bool get isCurrent; + Future commitTracking({ + List goals = const [], + List activities = const [], + List periods = const [], + String? deleteGoalId, + Book? book, + Book? previousBook, + String? readerBookId, + Map? readerPatch, + }); +} + +abstract interface class TrackingRepositoryOwner { + TrackingRepository get trackingRepository; +} diff --git a/app/lib/goals/goal_calendar.dart b/app/lib/goals/goal_calendar.dart new file mode 100644 index 00000000..58c1b0a9 --- /dev/null +++ b/app/lib/goals/goal_calendar.dart @@ -0,0 +1,91 @@ +import 'package:flutter_timezone/flutter_timezone.dart'; +import 'package:timezone/data/latest.dart' as data; +import 'package:timezone/timezone.dart' as tz; +import 'package:papyrus/models/reading_goal.dart'; + +class GoalRange { + const GoalRange(this.start, this.end); + final DateTime start; + final DateTime end; + bool contains(DateTime date) => !date.isBefore(start) && date.isBefore(end); +} + +class GoalCalendar { + static String systemTimezone = 'UTC'; + static bool _initialized = false; + static void initialize() { + if (_initialized) return; + data.initializeTimeZones(); + _initialized = true; + } + + static tz.Location location(String name) { + initialize(); + return name == 'UTC' ? tz.UTC : tz.getLocation(name); + } + + static Future deviceTimezone() async { + initialize(); + final zone = (await FlutterTimezone.getLocalTimezone()).identifier; + location(zone); + systemTimezone = zone; + return zone; + } + + static DateTime local(DateTime date, String zone) => tz.TZDateTime.from(date, location(zone)); + static DateTime midnight(DateTime date, String zone) { + final day = local(date, zone); + return tz.TZDateTime(location(zone), day.year, day.month, day.day).toUtc(); + } + + static DateTime nextDay(DateTime date, String zone) { + final day = local(date, zone); + return tz.TZDateTime(location(zone), day.year, day.month, day.day + 1).toUtc(); + } + + static DateTime deadline(DateTime civilDate, String zone) => + tz.TZDateTime(location(zone), civilDate.year, civilDate.month, civilDate.day + 1).toUtc(); + + static DateTime dayOffset(DateTime date, int offset, String zone) { + final day = local(date, zone); + return tz.TZDateTime(location(zone), day.year, day.month, day.day + offset).toUtc(); + } + + static GoalRange calendarPeriod(GoalPeriod period, DateTime date, String zone) { + final day = local(date, zone); + final loc = location(zone); + final start = switch (period) { + GoalPeriod.daily => tz.TZDateTime(loc, day.year, day.month, day.day), + GoalPeriod.weekly => tz.TZDateTime(loc, day.year, day.month, day.day - day.weekday + 1), + GoalPeriod.monthly => tz.TZDateTime(loc, day.year, day.month), + GoalPeriod.yearly => tz.TZDateTime(loc, day.year), + GoalPeriod.custom => throw ArgumentError('A deadline needs explicit dates'), + }; + final end = switch (period) { + GoalPeriod.daily => tz.TZDateTime(loc, start.year, start.month, start.day + 1), + GoalPeriod.weekly => tz.TZDateTime(loc, start.year, start.month, start.day + 7), + GoalPeriod.monthly => tz.TZDateTime(loc, start.year, start.month + 1), + GoalPeriod.yearly => tz.TZDateTime(loc, start.year + 1), + GoalPeriod.custom => throw ArgumentError('A deadline needs explicit dates'), + }; + return GoalRange(start.toUtc(), end.toUtc()); + } + + static DateTime effectiveNow(ReadingGoal goal, DateTime now) => + goal.isArchived && goal.rules.isNotEmpty && goal.rules.last.at.isBefore(now) ? goal.rules.last.at : now; + static GoalRange currentPeriod(ReadingGoal goal, DateTime now) => + goal.isRecurring && goal.period != GoalPeriod.custom && !now.isBefore(goal.startDate) + ? calendarPeriod(goal.period, effectiveNow(goal, now), goal.timezone) + : GoalRange(goal.startDate, goal.endDate); + static List pastPeriods(ReadingGoal goal, DateTime now) { + now = effectiveNow(goal, now); + final result = []; + var period = GoalRange(goal.startDate, goal.endDate); + while (!period.end.isAfter(now)) { + result.add(period); + if (!goal.isRecurring || goal.period == GoalPeriod.custom) break; + period = calendarPeriod(goal.period, period.end, goal.timezone); + } + return result; + } +} diff --git a/app/lib/goals/goal_progress.dart b/app/lib/goals/goal_progress.dart new file mode 100644 index 00000000..81fecd4a --- /dev/null +++ b/app/lib/goals/goal_progress.dart @@ -0,0 +1,187 @@ +import 'dart:math'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; + +class GoalProgress { + const GoalProgress({ + required this.goal, + required this.range, + required this.seconds, + required this.pages, + required this.finishedBooks, + required this.days, + required this.activities, + required this.estimated, + required this.qualifiedDays, + }); + final ReadingGoal goal; + final GoalRange range; + final int seconds; + final double pages; + final int finishedBooks; + final int days; + final List activities; + final bool estimated; + final Set qualifiedDays; + int get value => switch (goal.type) { + GoalType.books => finishedBooks, + GoalType.pages => pages.floor(), + GoalType.minutes => seconds ~/ 60, + GoalType.days => days, + }; + double get fraction => + ((goal.type == GoalType.minutes + ? seconds / 60 + : goal.type == GoalType.pages + ? pages + : value) / + goal.targetValue) + .clamp(0, 1); + bool get reached => fraction >= 1; + ReadingGoal get projected => goal.copyWith(currentValue: value, estimatedPages: estimated); +} + +GoalRule ruleAt(ReadingGoal goal, DateTime at) { + var rule = GoalRule( + at: goal.createdAt, + target: goal.targetValue, + title: goal.title, + active: goal.isActive, + archived: goal.isArchived, + ); + final rules = [...goal.rules]..sort((a, b) => a.at.compareTo(b.at)); + for (final next in rules) { + if (next.at.isAfter(at)) break; + rule = next; + } + return rule; +} + +bool matchesGoal(ReadingGoal goal, ReadingActivity activity) => switch (goal.scope) { + GoalScope.library => true, + GoalScope.book => activity.bookId == goal.scopeId, + GoalScope.shelf => activity.shelfIds.contains(goal.scopeId), +}; + +/// UTC interval unions remove concurrent-device double counting. +double unionLength(List<(double, double)> ranges) { + if (ranges.isEmpty) return 0; + ranges.sort((a, b) => a.$1.compareTo(b.$1)); + var start = ranges.first.$1; + var end = ranges.first.$2; + var total = 0.0; + for (final range in ranges.skip(1)) { + if (range.$1 <= end) { + end = max(end, range.$2); + } else { + total += end - start; + start = range.$1; + end = range.$2; + } + } + return total + end - start; +} + +GoalProgress projectGoal(ReadingGoal definition, Iterable ledger, DateTime now, {GoalRange? period}) { + final range = period ?? GoalCalendar.currentPeriod(definition, now); + final historical = !range.end.isAfter(now); + final rule = ruleAt(definition, historical ? range.end.subtract(const Duration(microseconds: 1)) : now); + final goal = definition.copyWith( + targetValue: rule.target, + title: rule.title, + startDate: range.start, + endDate: range.end, + isActive: historical ? rule.active : definition.isActive, + isArchived: historical + ? (!definition.isRecurring && definition.isArchived || rule.archived) + : definition.isArchived, + ); + final cutoff = definition.createdAt.isAfter(range.start) ? definition.createdAt : range.start; + final limit = range.end.isBefore(now) ? range.end : now; + final times = >{}; + final pages = >{}; + final scales = {}; + final books = {}; + final counted = []; + var manualPages = 0; + var estimated = false; + final boundaries = + definition.rules.map((rule) => rule.at).where((at) => at.isAfter(cutoff) && at.isBefore(limit)).toList()..sort(); + final effective = effectiveActivities(ledger) + ..sort((a, b) { + final order = a.endTime.compareTo(b.endTime); + return order == 0 ? a.id.compareTo(b.id) : order; + }); + for (final activity in effective) { + if (!matchesGoal(definition, activity)) continue; + var contributed = false; + final point = activity.kind == 'reading' && activity.endTime.isAfter(activity.startTime) + ? activity.endTime.subtract(const Duration(microseconds: 1)) + : activity.endTime; + final pointRule = ruleAt(definition, point); + if (!point.isBefore(cutoff) && + point.isBefore(range.end) && + !point.isAfter(now) && + pointRule.active && + !pointRule.archived) { + if (activity.kind == 'completion') { + books.add(activity.bookId); + contributed = true; + } + if (activity.kind == 'reading') { + manualPages += activity.pages; + contributed |= activity.pages > 0 || activity.coverage.isNotEmpty; + final day = GoalCalendar.midnight(point, goal.timezone).toIso8601String(); + for (final coverage in activity.coverage) { + final key = '$day:${activity.bookId}:${coverage.key}'; + pages.putIfAbsent(key, () => []).add((coverage.start, coverage.end)); + scales.putIfAbsent(key, () => coverage.pagesPerUnit); + estimated |= coverage.estimated; + } + } + } + if (activity.kind == 'reading') { + var cursor = activity.startTime.isAfter(cutoff) ? activity.startTime : cutoff; + final end = activity.endTime.isBefore(limit) ? activity.endTime : limit; + while (cursor.isBefore(end)) { + final day = GoalCalendar.midnight(cursor, goal.timezone); + var stop = GoalCalendar.nextDay(cursor, goal.timezone); + if (stop.isAfter(end)) stop = end; + for (final boundary in boundaries) { + if (boundary.isAfter(cursor) && boundary.isBefore(stop)) stop = boundary; + } + final active = ruleAt(definition, cursor); + if (active.active && !active.archived) { + times.putIfAbsent(day, () => []).add(( + cursor.microsecondsSinceEpoch / 1000000, + stop.microsecondsSinceEpoch / 1000000, + )); + contributed = true; + } + cursor = stop; + } + } + if (contributed) counted.add(activity); + } + final dailySeconds = times.map((day, intervals) => MapEntry(day, unionLength(intervals))); + final qualified = dailySeconds.entries + .where((entry) => entry.value >= definition.minimumMinutes * 60) + .map((entry) => entry.key) + .toSet(); + final pageCount = + manualPages + + pages.entries.fold(0, (total, entry) => total + unionLength(entry.value) * scales[entry.key]!); + counted.sort((a, b) => b.startTime.compareTo(a.startTime)); + return GoalProgress( + goal: goal, + range: range, + seconds: dailySeconds.values.fold(0, (a, b) => a + b).floor(), + pages: pageCount, + finishedBooks: books.length, + days: qualified.length, + activities: counted, + estimated: estimated, + qualifiedDays: qualified, + ); +} diff --git a/app/lib/main.dart b/app/lib/main.dart index a9e0863f..c54131fa 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,4 +1,7 @@ +import 'package:papyrus/services/reading_device_identity.dart'; import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:papyrus/themes/app_motion.dart'; @@ -42,13 +45,20 @@ import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'config/app_router.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); ensureBookImportDropPluginRegistered(); usePathUrlStrategy(); + try { + await GoalCalendar.deviceTimezone(); + } catch (_) { + GoalCalendar.initialize(); + } final prefs = await SharedPreferences.getInstance(); + await ReadingDeviceIdentity.initialize(prefs); runApp(Papyrus(prefs: prefs)); } @@ -212,7 +222,15 @@ class _PapyrusState extends State { activeApiConfig: () => _syncSettingsProvider.activeApiConfig, ); _powerSyncService = PapyrusPowerSyncService( + trackingCapability: () async { + final response = await http + .get(_syncSettingsProvider.activeApiConfig.endpoint('/sync/settings')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) return 0; + return (jsonDecode(response.body) as Map)['tracking_schema_version'] as int? ?? 0; + }, connectorFactory: () => PapyrusPowerSyncConnector( + supportsTracking: () => _powerSyncService.supportsTracking, authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig, onUploadComplete: () async { diff --git a/app/lib/models/goal_period_record.dart b/app/lib/models/goal_period_record.dart new file mode 100644 index 00000000..aa8798f6 --- /dev/null +++ b/app/lib/models/goal_period_record.dart @@ -0,0 +1,15 @@ +import 'package:papyrus/models/reading_goal.dart'; + +/// Retains the definition used by a closed period, not an editable counter. +class GoalPeriodRecord { + const GoalPeriodRecord({required this.id, required this.goalId, required this.definition}); + final String id; + final String goalId; + final ReadingGoal definition; + Map toJson() => {'id': id, 'goal_id': goalId, 'definition': definition.toJson()}; + factory GoalPeriodRecord.fromJson(Map json) => GoalPeriodRecord( + id: json['id'] as String, + goalId: json['goal_id'] as String, + definition: ReadingGoal.fromJson(Map.from(json['definition'] as Map)), + ); +} diff --git a/app/lib/models/reading_activity.dart b/app/lib/models/reading_activity.dart new file mode 100644 index 00000000..89994d03 --- /dev/null +++ b/app/lib/models/reading_activity.dart @@ -0,0 +1,170 @@ +import 'package:papyrus/models/reading_session.dart'; + +/// Stable document coverage, independent of the current screen's pagination. +class PageCoverage { + const PageCoverage({ + required this.key, + required this.start, + required this.end, + required this.pagesPerUnit, + this.estimated = false, + }); + final String key; + final double start; + final double end; + final double pagesPerUnit; + final bool estimated; + Map toJson() => { + 'key': key, + 'start': start, + 'end': end, + 'pages_per_unit': pagesPerUnit, + 'estimated': estimated, + }; + factory PageCoverage.fromJson(Map json) => PageCoverage( + key: json['key'] as String, + start: (json['start'] as num).toDouble(), + end: (json['end'] as num).toDouble(), + pagesPerUnit: (json['pages_per_unit'] as num).toDouble(), + estimated: json['estimated'] == true, + ); +} + +/// Append-only reading ledger. Corrections reverse a record and add a replacement. +class ReadingActivity { + const ReadingActivity({ + required this.id, + required this.bookId, + required this.bookTitle, + required this.startTime, + required this.endTime, + required this.createdAt, + this.sessionId, + this.recordedSeconds, + this.constituentIds = const [], + this.source = 'manual', + this.kind = 'reading', + this.deviceId = 'manual', + this.shelfIds = const [], + this.pages = 0, + this.coverage = const [], + this.note, + this.correctionOf, + }); + final String? sessionId; + final int? recordedSeconds; + final List constituentIds; + final String id; + final String bookId; + final String bookTitle; + final DateTime startTime; + final DateTime endTime; + final DateTime createdAt; + final String source; + final String kind; + final String deviceId; + final List shelfIds; + final int pages; + final List coverage; + final String? note; + final String? correctionOf; + bool get isEstimated => coverage.any((value) => value.estimated); + int get seconds => recordedSeconds ?? endTime.difference(startTime).inSeconds; + ReadingSession get session => ReadingSession( + id: id, + bookId: bookId, + startTime: startTime, + endTime: endTime, + startPosition: 0, + pagesRead: pages, + deviceType: source, + deviceName: deviceId, + createdAt: createdAt, + ); + Map toJson() => { + 'id': id, + 'session_id': sessionId, + 'book_id': bookId, + 'book_title': bookTitle, + 'start_time': startTime.toUtc().toIso8601String(), + 'end_time': endTime.toUtc().toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'source': source, + 'kind': kind, + 'device_id': deviceId, + 'shelf_ids': shelfIds, + 'pages': pages, + 'coverage': coverage.map((value) => value.toJson()).toList(), + 'note': note, + 'correction_of': correctionOf, + }; + factory ReadingActivity.fromJson(Map json) => ReadingActivity( + id: json['id'] as String, + sessionId: json['session_id'] as String?, + bookId: json['book_id'] as String, + bookTitle: json['book_title'] as String, + startTime: DateTime.parse(json['start_time'] as String).toUtc(), + endTime: DateTime.parse(json['end_time'] as String).toUtc(), + createdAt: DateTime.parse(json['created_at'] as String).toUtc(), + source: json['source'] as String? ?? 'manual', + kind: json['kind'] as String? ?? 'reading', + deviceId: json['device_id'] as String? ?? 'manual', + shelfIds: List.from(json['shelf_ids'] as List? ?? []), + pages: json['pages'] as int? ?? 0, + coverage: (json['coverage'] as List? ?? []) + .map((value) => PageCoverage.fromJson(Map.from(value as Map))) + .toList(), + note: json['note'] as String?, + correctionOf: json['correction_of'] as String?, + ); +} + +List effectiveActivities(Iterable activities) { + final unique = {for (final value in activities) value.id: value}; + final reversed = unique.values.where((value) => value.kind == 'reversal').map((value) => value.correctionOf).toSet(); + return unique.values.where((value) => value.kind != 'reversal' && !reversed.contains(value.id)).toList(); +} + +/// Checkpoints remain immutable records; presentation groups a reader opening. +List groupReadingActivities(Iterable ledger) { + final groups = >{}; + for (final entry in ledger) { + final key = entry.source == 'reader' && entry.kind == 'reading' && entry.sessionId != null + ? '${entry.bookId}:${entry.sessionId}' + : entry.id; + groups.putIfAbsent(key, () => []).add(entry); + } + final result = groups.values.map((entries) { + entries.sort((a, b) => a.startTime.compareTo(b.startTime)); + if (entries.length == 1) return entries.first; + final first = entries.first; + final ends = entries.map((a) => a.endTime).toList()..sort(); + var cursor = first.startTime; + var end = first.endTime; + var seconds = 0; + for (final entry in entries.skip(1)) { + if (entry.startTime.isAfter(end)) { + seconds += end.difference(cursor).inSeconds; + cursor = entry.startTime; + } + if (entry.endTime.isAfter(end)) end = entry.endTime; + } + seconds += end.difference(cursor).inSeconds; + return ReadingActivity( + id: first.id, + sessionId: first.sessionId, + bookId: first.bookId, + bookTitle: first.bookTitle, + startTime: first.startTime, + endTime: ends.last, + createdAt: first.createdAt, + source: first.source, + deviceId: first.deviceId, + shelfIds: first.shelfIds, + recordedSeconds: seconds, + coverage: entries.expand((a) => a.coverage).toList(), + constituentIds: entries.map((a) => a.id).toList(), + ); + }).toList()..sort((a, b) => b.endTime.compareTo(a.endTime)); + return result; +} diff --git a/app/lib/models/reading_goal.dart b/app/lib/models/reading_goal.dart index 7117e0e0..0dcbe3de 100644 --- a/app/lib/models/reading_goal.dart +++ b/app/lib/models/reading_goal.dart @@ -1,27 +1,55 @@ -/// Formats a duration in minutes to a short human-readable string. -/// -/// Examples: 0 → "0m", 30 → "30m", 60 → "1h", 90 → "1h 30m", 6000 → "100h" String formatDuration(int minutes) { final h = minutes ~/ 60; final m = minutes % 60; if (h == 0) return '${m}m'; - if (m == 0) return '${h}h'; - return '${h}h ${m}m'; + return m == 0 ? '${h}h' : '${h}h ${m}m'; } -/// Type of reading goal. -enum GoalType { books, pages, minutes } +enum GoalType { books, pages, minutes, days } -/// Period for the reading goal. -enum GoalPeriod { - daily, - weekly, - monthly, - yearly, - custom, // User-defined date range +enum GoalPeriod { daily, weekly, monthly, yearly, custom } + +enum GoalScope { library, book, shelf } + +String goalMetric(GoalType type) => switch (type) { + GoalType.books => 'books_count', + GoalType.pages => 'pages_count', + GoalType.minutes => 'reading_time', + GoalType.days => 'reading_days', +}; +GoalType parseGoalMetric(String value) => switch (value) { + 'books_count' || 'books' => GoalType.books, + 'pages_count' || 'pages' => GoalType.pages, + 'reading_time' || 'minutes' => GoalType.minutes, + 'reading_days' || 'days' => GoalType.days, + _ => throw FormatException('Unknown goal metric: $value'), +}; + +/// Rule revisions retain targets and pause/archive intervals across devices. +class GoalRule { + const GoalRule({required this.at, required this.target, this.title, this.active = true, this.archived = false}); + final DateTime at; + final int target; + final String? title; + final bool active; + final bool archived; + Map toJson() => { + 'at': at.toUtc().toIso8601String(), + 'target': target, + 'title': title, + 'active': active, + 'archived': archived, + }; + factory GoalRule.fromJson(Map json) => GoalRule( + at: DateTime.parse(json['at'] as String).toUtc(), + target: json['target'] as int, + title: json['title'] as String?, + active: json['active'] == true, + archived: json['archived'] == true, + ); } -/// Represents a user's reading goal. +/// Definition data is persisted; currentValue and streak are derived projections. class ReadingGoal { final String id; final String? title; @@ -32,136 +60,73 @@ class ReadingGoal { final GoalPeriod period; final DateTime startDate; final DateTime endDate; - - /// Whether this goal is currently active. + final DateTime createdAt; + final String timezone; + final GoalScope scope; + final String? scopeId; + final int minimumMinutes; + final List rules; final bool isActive; - - /// Whether this goal repeats after completion. - /// - true: Goal resets and repeats (e.g., "30 min daily" repeats each day) - /// - false: One-off goal that doesn't repeat after completion final bool isRecurring; - - /// Current streak for recurring daily goals. final int streak; - - /// Whether this goal is completed (archived). final bool isArchived; - - /// Completion date for archived goals. final DateTime? completedAt; - + final bool estimatedPages; const ReadingGoal({ required this.id, this.title, this.goalDescription, required this.type, required this.targetValue, - required this.currentValue, + this.currentValue = 0, required this.period, required this.startDate, required this.endDate, + DateTime? createdAt, + this.timezone = 'UTC', + this.scope = GoalScope.library, + this.scopeId, + this.minimumMinutes = 5, + this.rules = const [], this.isActive = true, this.isRecurring = true, this.streak = 0, this.isArchived = false, this.completedAt, - }); - - // Backwards compatibility aliases + this.estimatedPages = false, + }) : createdAt = createdAt ?? startDate; int get target => targetValue; int get current => currentValue; - - /// Progress as a value between 0.0 and 1.0. - double get progress => (currentValue / targetValue).clamp(0.0, 1.0); - - /// Number of items remaining to reach the goal. + double get progress => targetValue == 0 ? 0 : (currentValue / targetValue).clamp(0.0, 1.0); int get remaining => (targetValue - currentValue).clamp(0, targetValue); - - /// Whether the goal has been completed. bool get isCompleted => currentValue >= targetValue; - - /// Progress as a percentage string. String get progressLabel => '${(progress * 100).round()}%'; - - /// Display label for the goal type. - String get typeLabel { - switch (type) { - case GoalType.books: - return 'books'; - case GoalType.pages: - return 'pages'; - case GoalType.minutes: - return 'minutes'; - } - } - - /// Display label for the period. - String get periodLabel { - switch (period) { - case GoalPeriod.daily: - return 'daily'; - case GoalPeriod.weekly: - return 'this week'; - case GoalPeriod.monthly: - return 'this month'; - case GoalPeriod.yearly: - return 'this year'; - case GoalPeriod.custom: - return _formatDateRange(); - } - } - - String _formatDateRange() { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - final start = '${months[startDate.month - 1]} ${startDate.day}'; - final end = '${months[endDate.month - 1]} ${endDate.day}'; - if (startDate.year != endDate.year) { - return '$start, ${startDate.year} - $end, ${endDate.year}'; - } - return '$start - $end'; - } - - /// Whether this is a daily goal. + String get typeLabel => switch (type) { + GoalType.books => 'books', + GoalType.pages => 'pages', + GoalType.minutes => 'minutes', + GoalType.days => 'days', + }; + String get periodLabel => switch (period) { + GoalPeriod.daily => 'daily', + GoalPeriod.weekly => 'this week', + GoalPeriod.monthly => 'this month', + GoalPeriod.yearly => 'this year', + GoalPeriod.custom => 'by ${endDate.day}/${endDate.month}/${endDate.year}', + }; bool get isDaily => period == GoalPeriod.daily; - - /// Whether this is a yearly goal. bool get isYearly => period == GoalPeriod.yearly; - - /// Whether this is a custom date range goal. bool get isCustomPeriod => period == GoalPeriod.custom; - - /// Full goal description. - String get description { - if (goalDescription != null) return goalDescription!; - final valueStr = type == GoalType.minutes ? formatDuration(targetValue) : '$targetValue $typeLabel'; - if (isCustomPeriod) return 'Read $valueStr'; - return 'Read $valueStr $periodLabel'; - } - - /// Display title for the goal. - String get displayTitle { - if (title != null) return title!; - return description; - } - - /// Status text (e.g., "4 books to go"). - String get statusText { - if (isCompleted) { - return 'Goal completed!'; - } - final remainStr = type == GoalType.minutes ? formatDuration(remaining) : '$remaining $typeLabel'; - return '$remainStr to go'; - } - - /// Recurrence label for display. - String get recurrenceLabel { - if (isCustomPeriod) { - return 'Custom range'; - } - return isRecurring ? 'Recurring' : 'One-off'; - } - - /// Create a copy with updated fields. + String get description => + goalDescription ?? + (type == GoalType.days + ? 'Read on $targetValue days $periodLabel' + : 'Read ${type == GoalType.minutes ? formatDuration(targetValue) : '$targetValue $typeLabel'} $periodLabel'); + String get displayTitle => title?.trim().isNotEmpty == true ? title! : description; + String get statusText => isCompleted + ? 'Target reached' + : '${type == GoalType.minutes ? formatDuration(remaining) : '$remaining $typeLabel'} to go'; + String get recurrenceLabel => isRecurring && !isCustomPeriod ? 'Recurring' : 'One-off'; ReadingGoal copyWith({ String? id, String? title, @@ -172,69 +137,84 @@ class ReadingGoal { GoalPeriod? period, DateTime? startDate, DateTime? endDate, + DateTime? createdAt, + String? timezone, + GoalScope? scope, + String? scopeId, + int? minimumMinutes, + List? rules, bool? isActive, bool? isRecurring, int? streak, bool? isArchived, DateTime? completedAt, - }) { - return ReadingGoal( - id: id ?? this.id, - title: title ?? this.title, - goalDescription: goalDescription ?? this.goalDescription, - type: type ?? this.type, - targetValue: targetValue ?? this.targetValue, - currentValue: currentValue ?? this.currentValue, - period: period ?? this.period, - startDate: startDate ?? this.startDate, - endDate: endDate ?? this.endDate, - isActive: isActive ?? this.isActive, - isRecurring: isRecurring ?? this.isRecurring, - streak: streak ?? this.streak, - isArchived: isArchived ?? this.isArchived, - completedAt: completedAt ?? this.completedAt, - ); - } - - /// Convert to JSON for API/storage. - Map toJson() { - return { - 'id': id, - 'title': title, - 'description': goalDescription, - 'goal_type': type.name, - 'target_value': targetValue, - 'current_value': currentValue, - 'time_period': period.name, - 'start_date': startDate.toIso8601String(), - 'end_date': endDate.toIso8601String(), - 'is_active': isActive, - 'is_recurring': isRecurring, - 'streak': streak, - 'is_archived': isArchived, - 'completed_at': completedAt?.toIso8601String(), - }; - } - - /// Create from JSON. - factory ReadingGoal.fromJson(Map json) { - return ReadingGoal( - id: json['id'] as String, - title: json['title'] as String?, - goalDescription: json['description'] as String?, - type: GoalType.values.byName(json['goal_type'] as String), - targetValue: json['target_value'] as int, - currentValue: json['current_value'] as int, - period: GoalPeriod.values.byName(json['time_period'] as String), - startDate: DateTime.parse(json['start_date'] as String), - endDate: DateTime.parse(json['end_date'] as String), - isActive: json['is_active'] as bool? ?? true, - isRecurring: json['is_recurring'] as bool? ?? true, - streak: json['streak'] as int? ?? 0, - isArchived: json['is_archived'] as bool? ?? false, - completedAt: json['completed_at'] != null ? DateTime.parse(json['completed_at'] as String) : null, - ); - } + bool? estimatedPages, + }) => ReadingGoal( + id: id ?? this.id, + title: title ?? this.title, + goalDescription: goalDescription ?? this.goalDescription, + type: type ?? this.type, + targetValue: targetValue ?? this.targetValue, + currentValue: currentValue ?? this.currentValue, + period: period ?? this.period, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + createdAt: createdAt ?? this.createdAt, + timezone: timezone ?? this.timezone, + scope: scope ?? this.scope, + scopeId: scopeId ?? this.scopeId, + minimumMinutes: minimumMinutes ?? this.minimumMinutes, + rules: rules ?? this.rules, + isActive: isActive ?? this.isActive, + isRecurring: isRecurring ?? this.isRecurring, + streak: streak ?? this.streak, + isArchived: isArchived ?? this.isArchived, + completedAt: completedAt ?? this.completedAt, + estimatedPages: estimatedPages ?? this.estimatedPages, + ); + Map toJson() => { + 'id': id, + 'title': title, + 'description': goalDescription, + 'goal_type': goalMetric(type), + 'target_value': targetValue, + 'time_period': period.name, + 'start_date': startDate.toUtc().toIso8601String(), + 'end_date': endDate.toUtc().toIso8601String(), + 'created_at': createdAt.toUtc().toIso8601String(), + 'timezone': timezone, + 'scope': scope.name, + 'scope_id': scopeId, + 'minimum_minutes': minimumMinutes, + 'rules': rules.map((rule) => rule.toJson()).toList(), + 'is_active': isActive, + 'is_recurring': isRecurring, + 'is_archived': isArchived, + }; + factory ReadingGoal.fromJson(Map json) => ReadingGoal( + id: json['id'] as String, + title: json['title'] as String?, + goalDescription: json['description'] as String?, + type: parseGoalMetric(json['goal_type'] as String), + targetValue: json['target_value'] as int, + currentValue: json['current_value'] as int? ?? 0, + period: GoalPeriod.values.byName(json['time_period'] as String), + startDate: DateTime.parse(json['start_date'] as String).toUtc(), + endDate: DateTime.parse(json['end_date'] as String).toUtc(), + createdAt: DateTime.parse((json['created_at'] ?? json['start_date']) as String).toUtc(), + timezone: json['timezone'] as String? ?? 'UTC', + scope: GoalScope.values.byName(json['scope'] as String? ?? 'library'), + scopeId: json['scope_id'] as String?, + minimumMinutes: json['minimum_minutes'] as int? ?? 5, + rules: (json['rules'] as List? ?? []) + .map((value) => GoalRule.fromJson(Map.from(value as Map))) + .toList(), + isActive: json['is_active'] as bool? ?? true, + isRecurring: json['is_recurring'] as bool? ?? true, + isArchived: json['is_archived'] as bool? ?? false, + streak: json['streak'] as int? ?? 0, + completedAt: json['completed_at'] != null ? DateTime.parse(json['completed_at'] as String) : null, + ); /// Sample reading goals for backwards compatibility. static List get sampleGoals { diff --git a/app/lib/models/reading_session.dart b/app/lib/models/reading_session.dart index 97e12b89..5fbb1555 100644 --- a/app/lib/models/reading_session.dart +++ b/app/lib/models/reading_session.dart @@ -1,5 +1,6 @@ /// Reading session data model for tracking reading activity. class ReadingSession { + final int? recordedSeconds; final String id; final String bookId; final DateTime startTime; @@ -13,6 +14,7 @@ class ReadingSession { const ReadingSession({ required this.id, + this.recordedSeconds, required this.bookId, required this.startTime, this.endTime, @@ -26,6 +28,7 @@ class ReadingSession { /// Duration of the session in minutes. int get durationMinutes { + if (recordedSeconds != null) return recordedSeconds! ~/ 60; if (endTime == null) return 0; return endTime!.difference(startTime).inMinutes; } diff --git a/app/lib/pages/goals_page.dart b/app/lib/pages/goals_page.dart index b2fe73b0..d2831bc9 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -1,44 +1,43 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; +import 'package:papyrus/reader/reader_book_adapter.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/goals/active_goal_details_sheet.dart'; import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; -import 'package:papyrus/widgets/goals/completed_goal_chip.dart'; import 'package:papyrus/widgets/goals/goal_card.dart'; -import 'package:papyrus/widgets/statistics/stat_card.dart'; -import 'package:provider/provider.dart'; +import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; +import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; -/// Goals page displaying reading goals with progress tracking. -/// -/// Features a clean, unified design where all goals use consistent -/// linear progress bars regardless of their time period. class GoalsPage extends StatefulWidget { const GoalsPage({super.key}); - @override State createState() => _GoalsPageState(); } class _GoalsPageState extends State { - late GoalsProvider _provider; - bool _completedCollapsed = true; - - @override - void initState() { - super.initState(); - _provider = GoalsProvider(); - } - + final _provider = GoalsProvider(); + bool _history = false; + String? _filterGoal; + DateTimeRange? _filterDates; @override void didChangeDependencies() { super.didChangeDependencies(); - // Connect to DataStore for persistent storage - final dataStore = context.read(); - _provider.attach(dataStore); + _provider.attach(context.read()); } @override @@ -48,289 +47,413 @@ class _GoalsPageState extends State { } @override - Widget build(BuildContext context) { - return ChangeNotifierProvider.value( - value: _provider, - child: Consumer( - builder: (context, provider, _) { - final screenWidth = MediaQuery.of(context).size.width; - final isDesktop = screenWidth >= Breakpoints.desktopSmall; - - if (provider.isLoading) { - return _buildLoadingState(context); - } - - if (isDesktop) { - return _buildDesktopLayout(context, provider); - } - - return _buildMobileLayout(context, provider); - }, - ), - ); - } - - // ============================================================================ - // LOADING STATE - // ============================================================================ - - Widget _buildLoadingState(BuildContext context) { - return const Scaffold(body: Center(child: AppCircularProgressIndicator())); - } - - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - - Widget _buildMobileLayout(BuildContext context, GoalsProvider provider) { - final textTheme = Theme.of(context).textTheme; - final hasGoals = provider.hasActiveGoals || provider.hasCompletedGoals; - - return Scaffold( - floatingActionButton: FloatingActionButton( - onPressed: () => _showAddGoalSheet(context), - child: const Icon(Icons.add), - ), + Widget build(BuildContext context) => AnimatedBuilder( + animation: _provider, + builder: (context, _) => Scaffold( body: SafeArea( - child: hasGoals - ? RefreshIndicator( - onRefresh: provider.refresh, + child: LayoutBuilder( + builder: (context, constraints) { + final padding = constraints.maxWidth < Breakpoints.tablet ? Spacing.md : Spacing.xl; + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), child: ListView( - padding: const EdgeInsets.all(Spacing.md), + padding: EdgeInsets.all(padding), children: [ - // Stats summary row - if (provider.hasActiveGoals || provider.hasCompletedGoals) ...[ - _buildStatsRow(provider, isDesktop: false), - const SizedBox(height: Spacing.lg), - ], - // Active goals section - if (provider.hasActiveGoals) ...[ - Text('Active goals', style: textTheme.titleMedium), - const SizedBox(height: Spacing.sm), - ...provider.activeGoals.map( - (goal) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: GoalCard(goal: goal, onTap: () => _showGoalDetails(context, goal)), - ), - ), - ], - // Completed goals section - if (provider.hasCompletedGoals) ...[ - const SizedBox(height: Spacing.lg), - _buildCompletedHeader(context, provider.completedGoals.length), - const SizedBox(height: Spacing.sm), - if (!_completedCollapsed) ...[ - Opacity( - opacity: 0.6, - child: Column( - children: provider.completedGoals - .map( - (goal) => Padding( - padding: const EdgeInsets.only(bottom: Spacing.md), - child: GoalCard(goal: goal, onTap: () => _showGoalDetails(context, goal)), - ), - ) - .toList(), - ), - ), - ], - ], + LayoutBuilder( + builder: (context, box) { + final actions = Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + ), + onPressed: () => LogReadingSheet.show(context, provider: _provider), + icon: const Icon(Icons.edit_outlined), + label: const Text('Log reading'), + ), + FilledButton.icon( + style: FilledButton.styleFrom( + minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + ), + onPressed: () => AddGoalSheet.show(context, provider: _provider), + icon: const Icon(Icons.add), + label: const Text('New goal'), + ), + ], + ); + if (box.maxWidth < 520 || MediaQuery.textScalerOf(context).scale(16) > 24) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Goals', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: Spacing.md), + actions, + ], + ); + } + return Row( + children: [ + Expanded(child: Text('Goals', style: Theme.of(context).textTheme.headlineMedium)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + actions.children.first, + const SizedBox(width: Spacing.sm), + actions.children.last, + ], + ), + ], + ); + }, + ), const SizedBox(height: Spacing.lg), - ], - ), - ) - : _buildEmptyState(context), - ), - ); - } - - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - - Widget _buildDesktopLayout(BuildContext context, GoalsProvider provider) { - final textTheme = Theme.of(context).textTheme; - final hasGoals = provider.hasActiveGoals || provider.hasCompletedGoals; - - return Scaffold( - body: SafeArea( - child: hasGoals - ? SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Stats summary row - if (provider.hasActiveGoals || provider.hasCompletedGoals) ...[ - _buildStatsRow(provider, isDesktop: true), - const SizedBox(height: Spacing.lg), - ], - // Active goals section - if (provider.hasActiveGoals) ...[ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Active goals', style: textTheme.titleMedium), - FilledButton.icon( - onPressed: () => _showAddGoalSheet(context), - icon: const Icon(Icons.add), - label: const Text('New goal'), - ), + Align( + alignment: AlignmentDirectional.centerStart, + child: SegmentedButton( + segments: const [ + ButtonSegment(value: false, label: Text('Overview')), + ButtonSegment(value: true, label: Text('History')), ], + selected: {_history}, + onSelectionChanged: (selection) => setState(() => _history = selection.first), + ), + ), + const SizedBox(height: Spacing.xl), + if (_provider.isLoading) + const Center(child: AppCircularProgressIndicator()) + else if (_history) + ..._historyContent(context) + else + ..._overview(context), + if (_provider.error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_provider.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ), - const SizedBox(height: Spacing.md), - _buildGoalGrid(context, provider.activeGoals), - ], - // Completed goals section - if (provider.hasCompletedGoals) ...[ - const SizedBox(height: Spacing.xl), - _buildCompletedHeader(context, provider.completedGoals.length), - const SizedBox(height: Spacing.md), - if (!_completedCollapsed) - Opacity(opacity: 0.6, child: _buildGoalGrid(context, provider.completedGoals)), - ], ], ), - ) - : _buildEmptyState(context), - ), - ); - } - - Widget _buildGoalGrid(BuildContext context, List goals) { - return LayoutBuilder( - builder: (context, constraints) { - // Responsive columns: 1 for narrow, 2 for medium, 3 for wide - final crossAxisCount = _getColumnCount(constraints.maxWidth); - - // Card height: header(36) + gaps(32) + progress row(20) + bar(8) + footer(16) + padding(48) = 160 - // Adding extra padding for comfortable spacing - const cardHeight = 176.0; - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: Spacing.md, - crossAxisSpacing: Spacing.md, - mainAxisExtent: cardHeight, - ), - itemCount: goals.length, - itemBuilder: (context, index) { - return GoalCard(goal: goals[index], isDesktop: true, onTap: () => _showGoalDetails(context, goals[index])); + ), + ); }, - ); - }, - ); - } - - int _getColumnCount(double width) { - if (width >= 1200) return 3; - if (width >= 600) return 2; - return 1; - } - - // ============================================================================ - // STATS & HEADERS - // ============================================================================ - - Widget _buildStatsRow(GoalsProvider provider, {required bool isDesktop}) { - return Row( - children: [ - Expanded( - child: CompactStatCard(value: '${provider.activeGoals.length}', label: 'Active', isDesktop: isDesktop), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: CompactStatCard(value: '${provider.completedGoals.length}', label: 'Completed', isDesktop: isDesktop), ), - const SizedBox(width: Spacing.md), - Expanded( - child: CompactStatCard(value: '${_getBestStreak(provider)}', label: 'Best streak', isDesktop: isDesktop), + ), + ), + ); + + List _overview(BuildContext context) { + final current = _provider.current; + final today = current + .where((value) => value.goal.period == GoalPeriod.daily || value.goal.type == GoalType.days) + .toList(); + final longer = current.where((value) => !today.contains(value)).toList(); + final activity = groupReadingActivities(_provider.store.effectiveReadingActivities); + final texts = Theme.of(context).textTheme; + return [ + if (current.isEmpty) ...[ + const EmptyState.compact( + icon: Icons.flag_outlined, + title: 'No goals yet', + subtitle: + 'Choose a target that fits your reading. The reader tracks progress, and you can log physical books too.', + alignment: Alignment.topCenter, ), - ], - ); - } - - int _getBestStreak(GoalsProvider provider) { - return provider.activeGoals - .where((g) => g.isDaily && g.isRecurring && g.streak > 0) - .fold(0, (max, g) => g.streak > max ? g.streak : max); - } - - Widget _buildCompletedHeader(BuildContext context, int count) { - final textTheme = Theme.of(context).textTheme; - final colorScheme = Theme.of(context).colorScheme; - - return InkWell( - borderRadius: BorderRadius.circular(AppRadius.sm), - onTap: () => setState(() => _completedCollapsed = !_completedCollapsed), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - child: Row( + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, children: [ - Text('Completed goals', style: textTheme.titleMedium), - const SizedBox(width: Spacing.sm), - Text('($count)', style: textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const Spacer(), - Icon(_completedCollapsed ? Icons.expand_more : Icons.expand_less, color: colorScheme.onSurfaceVariant), + for (var i = 0; i < 5; i++) + ActionChip( + label: Text( + [ + '30 minutes daily', + '5 reading days weekly', + '12 books yearly', + '100 pages weekly', + 'Finish a book', + ][i], + ), + onPressed: () => AddGoalSheet.show(context, provider: _provider, preset: i), + ), ], ), - ), - ); + ], + if (today.isNotEmpty) ...[ + Text('Today · ${DateFormat.MMMMEEEEd().format(DateTime.now())}', style: texts.titleLarge), + const SizedBox(height: Spacing.md), + _cards(context, today), + const SizedBox(height: Spacing.xl), + ], + if (longer.isNotEmpty) ...[ + Text('Longer-term goals', style: texts.titleLarge), + const SizedBox(height: Spacing.md), + _cards(context, longer), + const SizedBox(height: Spacing.xl), + ], + if (activity.isNotEmpty) ...[ + Text('Recent activity', style: texts.titleLarge), + const SizedBox(height: Spacing.sm), + for (final entry in activity.take(5)) ReadingActivityTile(activity: entry, provider: _provider), + TextButton(onPressed: () => setState(() => _history = true), child: const Text('View history')), + ], + ]; } - // ============================================================================ - // EMPTY STATE - // ============================================================================ - Widget _buildEmptyState(BuildContext context) { - return EmptyState( - icon: Icons.flag_outlined, - title: 'No goals yet', - subtitle: 'Create your first reading goal to track your progress', - action: EmptyStateAction(onPressed: () => _showAddGoalSheet(context), icon: Icons.add, label: 'Create goal'), - ); + Widget _cards(BuildContext context, List goals, {bool historical = false}) => LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 760 && MediaQuery.textScalerOf(context).scale(16) <= 24 ? 2 : 1; + final width = (constraints.maxWidth - (columns - 1) * Spacing.md) / columns; + return Wrap( + spacing: Spacing.md, + runSpacing: Spacing.md, + children: [ + for (final progress in goals) + SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GoalCard( + goal: progress.goal, + progress: progress, + scopeLabel: _scopeLabel(progress.goal), + onTap: () => GoalDetailsSheet.show( + context, + goal: progress.goal, + provider: _provider, + historical: historical ? progress : null, + ), + onContinue: historical ? null : () => _continueReading(progress.goal), + onMenu: historical ? null : (action) => _goalAction(progress.goal, action), + ), + if (progress.goal.scope == GoalScope.book && progress.goal.type == GoalType.books && !historical) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm, left: Spacing.lg), + child: Text( + '${((_provider.store.getBook(progress.goal.scopeId!)?.currentPosition ?? 0) * 100).round()}% through the book · Finish confirmation required', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ], + ); + }, + ); + String _scopeLabel(ReadingGoal goal) => switch (goal.scope) { + GoalScope.library => 'Whole library', + GoalScope.book => _provider.store.getBook(goal.scopeId!)?.title ?? 'Removed book', + GoalScope.shelf => _provider.store.getShelf(goal.scopeId!)?.name ?? 'Removed shelf', + }; + List _historyContent(BuildContext context) { + final periods = _provider.history; + final names = { + for (final record in periods) record.goal.id: record.goal.displayTitle, + for (final goal in _provider.store.goalDefinitions) goal.id: goal.displayTitle, + }; + if (!names.containsKey(_filterGoal)) _filterGoal = null; + final selectedDefinition = _filterGoal == null + ? null + : _provider.store.getReadingGoal(_filterGoal!) ?? periods.where((p) => p.goal.id == _filterGoal).first.goal; + final activity = + _provider.store.readingActivities + .where( + (a) => + a.kind != 'reversal' && + (selectedDefinition == null || matchesGoal(selectedDefinition, a)) && + (_filterDates == null || + !a.endTime.isBefore(_filterDates!.start) && + a.endTime.isBefore(_filterDates!.end.copyWith(day: _filterDates!.end.day + 1))), + ) + .toList() + ..sort((a, b) => b.endTime.compareTo(a.endTime)); + final corrected = _provider.store.readingActivities + .where((a) => a.kind == 'reversal') + .map((a) => a.correctionOf) + .toSet(); + final filtered = periods + .where( + (p) => + (_filterGoal == null || p.goal.id == _filterGoal) && + (_filterDates == null || + p.range.start.isBefore(_filterDates!.end.copyWith(day: _filterDates!.end.day + 1)) && + p.range.end.isAfter(_filterDates!.start)), + ) + .toList(); + final archived = _provider.store.goalDefinitions + .where((goal) => goal.isArchived && (_filterGoal == null || _filterGoal == goal.id)) + .map(_provider.progress) + .toList(); + return [ + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + width: (MediaQuery.sizeOf(context).width - Spacing.xl * 2).clamp(120, 300).toDouble(), + child: DropdownButtonFormField( + key: ValueKey(_filterGoal), + initialValue: _filterGoal ?? '', + isExpanded: true, + decoration: const InputDecoration(labelText: 'Goal'), + items: [ + const DropdownMenuItem(value: '', child: Text('All goals')), + for (final name in names.entries) + DropdownMenuItem( + value: name.key, + child: Text(name.value, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (value) => setState(() => _filterGoal = value == '' ? null : value), + ), + ), + OutlinedButton.icon( + onPressed: () async { + final dates = await showAppDateRangePicker( + context: context, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + initialDateRange: _filterDates, + ); + if (dates != null && mounted) setState(() => _filterDates = dates); + }, + icon: const Icon(Icons.date_range_outlined), + label: Text( + _filterDates == null + ? 'Filter dates' + : '${DateFormat.MMMd().format(_filterDates!.start)} – ${DateFormat.MMMd().format(_filterDates!.end)}', + ), + ), + if (_filterDates != null) + TextButton(onPressed: () => setState(() => _filterDates = null), child: const Text('Clear dates')), + ], + ), + const SizedBox(height: Spacing.xl), + if (filtered.isNotEmpty) ...[ + Text('Previous periods', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.md), + _cards(context, filtered, historical: true), + const SizedBox(height: Spacing.xl), + ], + if (archived.isNotEmpty) ...[ + Text('Archived goals', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.md), + _cards(context, archived, historical: true), + const SizedBox(height: Spacing.xl), + ], + Text('Reading activity', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.sm), + if (activity.isEmpty) + const EmptyState.compact( + icon: Icons.history_outlined, + title: 'No reading activity', + subtitle: 'Reading and manual logs will appear here.', + alignment: Alignment.topCenter, + ) + else + for (final entry in [ + ...groupReadingActivities(activity.where((entry) => !corrected.contains(entry.id))), + ...groupReadingActivities(activity.where((entry) => corrected.contains(entry.id))), + ]) + ReadingActivityTile(activity: entry, provider: _provider, corrected: corrected.contains(entry.id)), + ]; } - // ============================================================================ - // ACTIONS - // ============================================================================ - - void _showAddGoalSheet(BuildContext context) { - AddGoalSheet.show( - context, - onCreate: (type, target, period, isRecurring, startDate, endDate) { - _provider.createGoal( - type: type, - target: target, - period: period, - isRecurring: isRecurring, - startDate: startDate, - endDate: endDate, - ); - }, - ); + Future _goalAction(ReadingGoal goal, String action) async { + if (action == 'edit') { + await AddGoalSheet.show(context, provider: _provider, editing: _provider.store.getReadingGoal(goal.id)); + return; + } + if (action == 'delete') { + await GoalDetailsSheet.show(context, goal: goal, provider: _provider); + return; + } + try { + if (action == 'pause') await _provider.pauseGoal(goal.id, goal.isActive); + if (action == 'archive') { + await _provider.archiveGoal(goal.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Goal archived'), + action: SnackBarAction(label: 'Undo', onPressed: () => _restore(goal)), + ), + ); + } + } + } catch (error) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + } } - void _showGoalDetails(BuildContext context, ReadingGoal goal) { - if (goal.isCompleted) { - CompletedGoalChip.showDetailsSheet(context, goal: goal, onDelete: () => _provider.deleteGoal(goal.id)); + Future _restore(ReadingGoal goal) => _provider.restoreGoal(goal.id); + Future _continueReading(ReadingGoal goal) async { + final candidates = + _provider.store.books + .where( + (book) => + goal.scope == GoalScope.library || + goal.scope == GoalScope.book && book.id == goal.scopeId || + goal.scope == GoalScope.shelf && _provider.store.getShelfIdsForBook(book.id).contains(goal.scopeId), + ) + .toList() + ..sort((a, b) => (b.lastReadAt ?? b.addedAt).compareTo(a.lastReadAt ?? a.addedAt)); + if (candidates.isEmpty) { + await LogReadingSheet.show(context, provider: _provider); return; } - - ActiveGoalDetailsSheet.show( - context, - goal: goal, - onUpdateProgress: (newProgress) { - _provider.updateGoalProgress(goal.id, newProgress); - }, - onEdit: (newTarget) { - _provider.updateGoal(goalId: goal.id, target: newTarget); - }, - onDelete: () { - _provider.deleteGoal(goal.id); - }, - ); + Book? selected; + final readable = candidates + .where( + (book) => + !book.isPhysical && + ReaderBookAdapter.formatFor(book.fileFormat) != null && + book.readingStatus == LibraryReadingStatus.inProgress, + ) + .toList(); + if (readable.isNotEmpty) { + selected = readable.first; + } else if (goal.scope == GoalScope.book) { + selected = candidates.first; + } else { + selected = await showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + sheetAnimationStyle: AppMotion.animationStyle(context), + builder: (context) => AppBottomSheet( + title: 'Choose a book', + onClose: () => Navigator.pop(context), + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final book in candidates) + ListTile( + title: Text(book.title), + subtitle: Text(book.isPhysical ? 'Log physical-book reading' : book.author), + onTap: () => Navigator.pop(context, book), + ), + ], + ), + ), + ); + } + if (selected == null || !mounted) return; + var available = selected.fileMediaId != null; + try { + available |= await context.read().hasBookFile(selected.id); + } catch (_) {} + if (!mounted) return; + if (selected.isPhysical || ReaderBookAdapter.formatFor(selected.fileFormat) == null || !available) { + await LogReadingSheet.show(context, provider: _provider, book: selected); + return; + } + context.goNamed('BOOK_READER', pathParameters: {'bookId': selected.id}); } } diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index ef1948fa..f90bb11f 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -1,4 +1,8 @@ +import 'package:papyrus/services/reading_device_identity.dart'; +import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:papyrus/reader/reading_activity_tracker.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_cache_service.dart'; @@ -30,6 +34,32 @@ class _ReaderPageState extends State { ReaderSession? _session; String? _error; bool _startedLoading = false; + ReadingActivityTracker? _tracker; + DataStore? _trackingStore; + Timer? _finishTimer; + bool _finishPrompted = false; + bool _promptOpen = false; + bool _foreground = true; + String? _trackingError; + late final AppLifecycleListener _lifecycle; + + @override + void initState() { + super.initState(); + _foreground = + WidgetsBinding.instance.lifecycleState == null || + WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; + _lifecycle = AppLifecycleListener( + onStateChange: (state) { + _foreground = state == AppLifecycleState.resumed; + _tracker?.setForeground(_foreground && !_promptOpen); + if (!_foreground) { + _finishTimer?.cancel(); + _finishTimer = null; + } + }, + ); + } @override void didChangeDependencies() { @@ -42,9 +72,11 @@ class _ReaderPageState extends State { Future _load() async { final dataStore = context.read(); + final repository = dataStore.requireBookRepository(); + final trackingRepository = dataStore.trackingRepository; var book = dataStore.getBook(widget.bookId); - book ??= await dataStore.requireBookRepository().getById(widget.bookId); + book ??= await repository.getById(widget.bookId); if (book == null && !dataStore.isLoaded) { await dataStore.waitUntilLoaded(); book = dataStore.getBook(widget.bookId); @@ -75,7 +107,36 @@ class _ReaderPageState extends State { ); if (!mounted) return; - final session = ReaderSession(book: book, saveBook: dataStore.updateBook); + if (!dataStore.isBookRepositoryCurrent(repository)) return; + ReadingActivityTracker? tracker; + if (trackingRepository != null) { + if (!mounted || !trackingRepository.isCurrent) return; + tracker = ReadingActivityTracker( + repository: trackingRepository, + book: book, + shelfIds: dataStore.getShelfIdsForBook(book.id), + deviceId: ReadingDeviceIdentity.current, + onError: (error) { + if (!mounted || _trackingError != null) return; + _trackingError = error.toString(); + ScaffoldMessenger.maybeOf( + context, + )?.showSnackBar(const SnackBar(content: Text('Reading activity could not be saved. Retrying.'))); + }, + ); + tracker.setForeground(_foreground); + } + _tracker = tracker; + if (tracker != null) { + _trackingStore = dataStore; + dataStore.addListener(_scopeChanged); + } + final session = ReaderSession( + book: book, + tracker: tracker, + saveBook: (updated) => unawaited(dataStore.updateBookAndWait(updated, repository: repository, previous: book)), + ); + final preferences = ReaderBookAdapter.preferencesFor( context.read(), Theme.of(context).colorScheme, @@ -100,8 +161,13 @@ class _ReaderPageState extends State { } } + void _scopeChanged() => _tracker?.updateScope(_trackingStore!.getShelfIdsForBook(widget.bookId)); + @override void dispose() { + _trackingStore?.removeListener(_scopeChanged); + _finishTimer?.cancel(); + _lifecycle.dispose(); _session?.dispose(); super.dispose(); } @@ -136,6 +202,7 @@ class _ReaderPageState extends State { initialLocator: _initialLocator, initialPreferences: preferences, onLocatorChanged: _session!.updateLocator, + onActivity: _onActivity, onPreferencesChanged: (updated) { ReaderBookAdapter.persistPreferences(context.read(), updated); }, @@ -143,8 +210,79 @@ class _ReaderPageState extends State { ); } - void _close() { - _session?.flush(); + void _onActivity(ReaderActivityEvent event) { + _tracker?.onActivity(event); + if (!event.atEnd || !event.visible || !event.ready) { + _finishTimer?.cancel(); + _finishTimer = null; + if (!event.atEnd) _finishPrompted = false; + return; + } + if (_foreground && + !_finishPrompted && + _finishTimer == null && + context.read().getBook(widget.bookId)?.readingStatus != LibraryReadingStatus.completed) { + _finishTimer = Timer(const Duration(seconds: 10), _confirmFinished); + } + } + + Future _confirmFinished() async { + _finishTimer = null; + if (!mounted || !_foreground || _tracker?.repository.isCurrent == false) return; + _finishPrompted = true; + _promptOpen = true; + _tracker?.setForeground(false); + final finished = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Finished this book?'), + content: const Text('Mark it as finished to update your library and reading goals.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Not yet')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Mark finished')), + ], + ), + ); + if (!mounted) return; + try { + if (finished == true && _tracker?.repository.isCurrent != false) { + final store = context.read(); + final book = store.getBook(widget.bookId); + if (book != null) { + await store.updateBookAndWait( + book.copyWith(readingStatus: LibraryReadingStatus.completed, completedAt: DateTime.now()), + previous: book, + completionSource: 'reader', + completionDeviceId: _tracker?.deviceId, + ); + } + } + } catch (_) { + if (mounted) { + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text('Could not save completion. Please try again from book details.')), + ); + } + } finally { + _promptOpen = false; + _tracker?.setForeground(_foreground); + } + } + + Future _close() async { + _finishTimer?.cancel(); + try { + await _tracker?.close(); + await _session?.flush(); + } catch (_) { + if (mounted) { + ScaffoldMessenger.maybeOf( + context, + )?.showSnackBar(const SnackBar(content: Text('Could not save reading activity. Please try leaving again.'))); + } + return; + } + if (!mounted) return; if (context.canPop()) { context.pop(); return; diff --git a/app/lib/powersync/library_database.dart b/app/lib/powersync/library_database.dart index d552010b..7cde2cbb 100644 --- a/app/lib/powersync/library_database.dart +++ b/app/lib/powersync/library_database.dart @@ -1,4 +1,8 @@ import 'dart:convert'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/goal_period_record.dart'; import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/book.dart'; @@ -8,10 +12,13 @@ import 'package:powersync/powersync.dart'; import 'package:sqlite_async/sqlite_async.dart'; /// A handle bound to one opened library, invalidated before a profile switch. -class LibraryDatabase implements LibraryMembershipWriter { +class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { final PowerSyncDatabase database; final Future Function() onWrite; bool active = true; + bool trackingSupported = false; + @override + bool get isCurrent => active; LibraryDatabase(this.database, this.onWrite); @@ -152,7 +159,28 @@ class LibraryDatabase implements LibraryMembershipWriter { Future> rows(LibraryRowMapper mapper) async => (await tx.getAll( 'SELECT * FROM ${mapper.table}', )).map((row) => mapper.fromRow(Map.from(row))).toList(); + Future> trackingRows(String table, T Function(Map) decode) async { + final rows = { + for (final row in await tx.getAll('SELECT id, payload FROM $table')) + row['id'] as String: jsonDecode(row['payload'] as String) as Map, + }; + for (final staged in await tx.getAll( + 'SELECT row_id, payload, deleted FROM tracking_staging WHERE table_name = ?', + [table], + )) { + if (staged['deleted'] == 1) { + rows.remove(staged['row_id']); + } else { + rows[staged['row_id'] as String] = jsonDecode(staged['payload'] as String) as Map; + } + } + return rows.values.map((row) => decode(Map.from(row))).toList(); + } + return LibrarySnapshot( + goals: await trackingRows('reading_goals', ReadingGoal.fromJson), + activities: await trackingRows('reading_activities', ReadingActivity.fromJson), + goalPeriods: await trackingRows('goal_periods', GoalPeriodRecord.fromJson), books: (await tx.getAll( 'SELECT * FROM books ORDER BY added_at DESC', )).map((row) => PowerSyncBookMapper.fromRow(Map.from(row))).toList(), @@ -166,6 +194,109 @@ class LibraryDatabase implements LibraryMembershipWriter { ); }); + Future enableTracking() async { + final previous = trackingSupported; + try { + await write((tx) async { + trackingSupported = true; + final staged = await tx.getAll('SELECT * FROM tracking_staging ORDER BY table_name DESC'); + for (final table in trackingTableNames) { + for (final row in staged.where((row) => row['table_name'] == table)) { + if (row['deleted'] == 1) { + await tx.execute('DELETE FROM $table WHERE id = ?', [row['row_id']]); + } else { + await _trackingRow( + tx, + table, + Map.from(jsonDecode(row['payload'] as String) as Map), + force: true, + ); + } + await tx.execute('DELETE FROM tracking_staging WHERE id = ?', [row['id']]); + } + } + }); + } catch (_) { + trackingSupported = previous; + rethrow; + } + } + + Future _trackingRow( + SqliteWriteContext tx, + String table, + Map payload, { + bool deleted = false, + bool force = false, + }) async { + final id = payload['id'] as String; + if (!trackingSupported) { + await tx.execute( + 'INSERT OR REPLACE INTO tracking_staging (id, table_name, row_id, payload, deleted) VALUES (?, ?, ?, ?, ?)', + ['$table:$id', table, id, jsonEncode(payload), deleted ? 1 : 0], + ); + return; + } + if (deleted) { + await tx.execute('DELETE FROM $table WHERE id = ?', [id]); + return; + } + final date = DateTime.now().toUtc().toIso8601String(); + final existing = force ? await tx.getOptional('SELECT id FROM $table WHERE id = ?', [id]) : null; + await upsertRow(tx, table, { + 'id': id, + 'payload': jsonEncode(payload), + 'created_at': date, + 'updated_at': date, + }, previous: force && existing != null ? {'payload': null} : null); + } + + @override + Future commitTracking({ + List goals = const [], + List activities = const [], + List periods = const [], + String? deleteGoalId, + Book? book, + Book? previousBook, + String? readerBookId, + Map? readerPatch, + }) => write((tx) async { + if (book != null) { + await upsertRow( + tx, + 'books', + PowerSyncBookMapper.toRow(book), + previous: previousBook == null ? null : PowerSyncBookMapper.toRow(previousBook), + ); + } + if (readerBookId != null && readerPatch != null) { + final row = await tx.getOptional('SELECT * FROM books WHERE id = ?', [readerBookId]); + if (row != null) { + final patch = Map.from(readerPatch); + final metadata = row['custom_metadata'] is String + ? Map.from(jsonDecode(row['custom_metadata'] as String) as Map) + : {}; + final locator = patch.remove('reader_locator'); + metadata['reader_locator'] = locator; + patch['custom_metadata'] = jsonEncode(metadata); + patch['started_at'] = row['started_at'] ?? patch['last_read_at']; + if (row['reading_status'] == null || row['reading_status'] == 'unread') patch['reading_status'] = 'inProgress'; + await upsertRow(tx, 'books', {...row, ...patch}); + } + } + for (final goal in goals) { + await _trackingRow(tx, 'reading_goals', goal.toJson()); + } + for (final activity in activities) { + await _trackingRow(tx, 'reading_activities', activity.toJson()); + } + for (final period in periods) { + await _trackingRow(tx, 'goal_periods', period.toJson()); + } + if (deleteGoalId != null) await _trackingRow(tx, 'reading_goals', {'id': deleteGoalId}, deleted: true); + }); + /// Expands legacy local metadata once without clearing data or the CRUD queue. Future migrateLegacyBooks() async { await database.writeTransaction((tx) async { diff --git a/app/lib/powersync/library_row_mapper.dart b/app/lib/powersync/library_row_mapper.dart index efa9400a..3aed29ee 100644 --- a/app/lib/powersync/library_row_mapper.dart +++ b/app/lib/powersync/library_row_mapper.dart @@ -16,7 +16,10 @@ class LibraryRowMapper { const LibraryRowMapper(this.table, this.toRow, this.fromRow); } +const trackingTableNames = ['reading_goals', 'reading_activities', 'goal_periods']; const libraryTableNames = [ + ...trackingTableNames, + 'tracking_staging', 'books', 'shelves', 'tags', @@ -99,7 +102,7 @@ Map decodeLibraryRow(Map row) => row.map((key, if (['is_smart', 'is_pinned', 'icon_match_text_direction'].contains(key)) { return MapEntry(key, value == true || value == 1); } - if (['tags', 'location'].contains(key) && value is String) { + if (['tags', 'location', 'payload'].contains(key) && value is String) { return MapEntry(key, jsonDecode(value)); } return MapEntry(key, value); diff --git a/app/lib/powersync/papyrus_powersync_connector.dart b/app/lib/powersync/papyrus_powersync_connector.dart index 6c4de579..be186e8e 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; @@ -9,8 +10,14 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { final AuthRepository authRepository; final PapyrusApiConfig config; final Future Function()? onUploadComplete; + final bool Function()? supportsTracking; - PapyrusPowerSyncConnector({required this.authRepository, required this.config, this.onUploadComplete}); + PapyrusPowerSyncConnector({ + required this.authRepository, + required this.config, + this.onUploadComplete, + this.supportsTracking, + }); @override Future fetchCredentials() async { @@ -40,7 +47,22 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { return; } - final batch = powerSyncUploadBatchFromCrud(transaction.crud); + final deferred = supportsTracking?.call() == false + ? transaction.crud.where((entry) => trackingTableNames.contains(entry.table)).toList() + : []; + if (deferred.isNotEmpty) { + await database.writeTransaction((tx) async { + for (final entry in deferred) { + final row = await tx.getOptional('SELECT payload FROM ${entry.table} WHERE id = ?', [entry.id]); + final payload = row?['payload'] as String? ?? jsonEncode({'id': entry.id}); + await tx.execute( + 'INSERT OR REPLACE INTO tracking_staging (id, table_name, row_id, payload, deleted) VALUES (?, ?, ?, ?, ?)', + ['${entry.table}:${entry.id}', entry.table, entry.id, payload, row == null ? 1 : 0], + ); + } + }); + } + final batch = powerSyncUploadBatchFromCrud(transaction.crud.where((entry) => !deferred.contains(entry)).toList()); if (batch.isEmpty) { await transaction.complete(); diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index 2aa3fa48..11ddade0 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -124,7 +124,23 @@ const _bookTagsColumns = [ Column.text('created_at'), ]; +const _trackingColumns = [ + Column.text('owner_user_id'), + Column.text('payload'), + Column.text('created_at'), + Column.text('updated_at'), +]; +const _stagingColumns = [ + Column.text('table_name'), + Column.text('row_id'), + Column.text('payload'), + Column.integer('deleted'), +]; const papyrusAccountSchema = Schema([ + Table('reading_goals', _trackingColumns), + Table('reading_activities', _trackingColumns), + Table('goal_periods', _trackingColumns), + Table.localOnly('tracking_staging', _stagingColumns), Table('books', _bookColumns, indexes: _bookIndexes), Table('shelves', _shelvesColumns), Table('tags', _tagsColumns), @@ -137,6 +153,10 @@ const papyrusAccountSchema = Schema([ ]); const papyrusGuestSchema = Schema([ + Table.localOnly('reading_goals', _trackingColumns), + Table.localOnly('reading_activities', _trackingColumns), + Table.localOnly('goal_periods', _trackingColumns), + Table.localOnly('tracking_staging', _stagingColumns), Table.localOnly('books', _bookColumns, indexes: _bookIndexes), Table.localOnly('shelves', _shelvesColumns), Table.localOnly('tags', _tagsColumns), diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index af788c8e..e364c488 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; import 'package:flutter/foundation.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; @@ -46,10 +47,13 @@ class SyncStateRevisionCoordinator { void invalidate() => _revision++; } -class PapyrusPowerSyncService implements BookRepository, LibraryRepository { +class PapyrusPowerSyncService implements BookRepository, LibraryRepository, TrackingRepositoryOwner { final PowerSyncConnectorFactory connectorFactory; final LibraryDatabasePathResolver? pathResolver; final bool connectAuthenticated; + final Future Function()? trackingCapability; + @override + TrackingRepository get trackingRepository => _activeLibrary; final StreamController> _booksController = StreamController>.broadcast(); final StreamController _syncStateController = StreamController.broadcast(); @@ -59,6 +63,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { PowerSyncDatabase? _database; LibraryDatabase? _library; LibrarySnapshot? _snapshot; + bool get supportsTracking => _library?.trackingSupported == true; final _libraryController = StreamController.broadcast(); LibraryDatabase get _activeLibrary { @@ -103,7 +108,12 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { BookMetadataSyncState _bookMetadataSyncState = const BookMetadataSyncState(); final SyncStateRevisionCoordinator _syncStateRevisions = SyncStateRevisionCoordinator(); - PapyrusPowerSyncService({required this.connectorFactory, this.pathResolver, this.connectAuthenticated = true}); + PapyrusPowerSyncService({ + required this.connectorFactory, + this.pathResolver, + this.connectAuthenticated = true, + this.trackingCapability, + }); LibraryDatabaseMode? get mode => _mode; SyncState get syncState => _syncState; @@ -129,6 +139,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { } final database = _requireDatabase(); if (online) { + await _prepareTracking(); await database.connect(connector: connectorFactory()); } else { await database.disconnect(); @@ -143,6 +154,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { final database = _requireDatabase(); _watchStatus(database); await database.disconnect(); + await _prepareTracking(); await database.connect(connector: connectorFactory()); } @@ -263,11 +275,13 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { await database.initialize(); _database = database; _library = LibraryDatabase(database, _refreshPendingWrites); + _library!.trackingSupported = mode == LibraryDatabaseMode.guest; await _library!.migrateLegacyBooks(); _watchBooks(database); if (mode == LibraryDatabaseMode.authenticated && connectAuthenticated) { _watchStatus(database); + await _prepareTracking(); await database.connect(connector: connectorFactory()); } else { _setSyncState(const SyncState()); @@ -276,6 +290,21 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { await _refreshPendingWrites(); } + bool _checkingTracking = false; + Future _prepareTracking() async { + final library = _activeLibrary; + if (_checkingTracking || library.trackingSupported) return; + _checkingTracking = true; + try { + final version = await trackingCapability?.call() ?? 0; + if (library.active && version >= 1) await library.enableTracking(); + } catch (_) { + // Discovery failure keeps tracking local and does not prevent library sync. + } finally { + _checkingTracking = false; + } + } + void _watchBooks(PowerSyncDatabase database) { unawaited(_booksSubscription?.cancel()); final library = _activeLibrary; @@ -304,6 +333,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { } Future _setStatusFromPowerSync(SyncStatus status) async { + if (status.connected && _library?.trackingSupported == false) unawaited(_prepareTracking()); final revision = _syncStateRevisions.beginTransportUpdate(); final pending = await _readPendingWrites(); if (!_syncStateRevisions.isCurrent(revision)) return; diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 2faa5c3f..be2a5a08 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -294,9 +294,7 @@ class BookDetailsProvider extends ChangeNotifier { _book = _book!.copyWith( currentPage: page, currentPosition: position.clamp(0.0, 1.0), - readingStatus: position >= 1.0 - ? LibraryReadingStatus.completed - : position > 0 + readingStatus: position > 0 && _book!.readingStatus == LibraryReadingStatus.unread ? LibraryReadingStatus.inProgress : _book!.readingStatus, lastReadAt: DateTime.now(), diff --git a/app/lib/providers/dashboard_provider.dart b/app/lib/providers/dashboard_provider.dart index cb6722a0..5f210cc9 100644 --- a/app/lib/providers/dashboard_provider.dart +++ b/app/lib/providers/dashboard_provider.dart @@ -36,6 +36,7 @@ class DashboardProvider extends ChangeNotifier { } void _onDataStoreChanged() { + _loadActivityData(); notifyListeners(); } @@ -83,8 +84,7 @@ class DashboardProvider extends ChangeNotifier { if (_dataStore == null) return 0; final today = DateTime.now(); final todayStart = DateTime(today.year, today.month, today.day); - final todaySessions = _dataStore!.readingSessions.where((s) => s.startTime.isAfter(todayStart)); - return todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); + return _dataStore!.activityTotals(todayStart, DateTime(today.year, today.month, today.day + 1)).seconds ~/ 60; } int get weekOffset => _weekOffset; @@ -102,7 +102,7 @@ class DashboardProvider extends ChangeNotifier { /// Total reading minutes from all sessions. int get totalReadingMinutes { if (_dataStore == null) return 0; - return _dataStore!.readingSessions.fold(0, (sum, s) => sum + s.durationMinutes); + return _dataStore!.activityTotals(DateTime.utc(1900), DateTime.now().toUtc()).seconds ~/ 60; } ActivityPeriod get activityPeriod => _activityPeriod; @@ -159,8 +159,7 @@ class DashboardProvider extends ChangeNotifier { notifyListeners(); try { - // Simulate network delay for realistic UX - await Future.delayed(const Duration(milliseconds: 100)); + await _dataStore?.waitUntilLoaded(); _loadActivityData(); @@ -243,7 +242,7 @@ class DashboardProvider extends ChangeNotifier { void _loadActivityData() { if (_dataStore == null) { - _weeklyActivity = DailyActivity.sampleWeek; + _weeklyActivity = []; return; } @@ -255,22 +254,20 @@ class DashboardProvider extends ChangeNotifier { if (_dataStore == null) return []; final now = DateTime.now(); - final weekStart = now.subtract(Duration(days: now.weekday - 1 + (-offset * 7))); - - return List.generate(7, (i) { - final date = weekStart.add(Duration(days: i)); - final dayStart = DateTime(date.year, date.month, date.day); - final dayEnd = dayStart.add(const Duration(days: 1)); - - // Get sessions for this day - final daySessions = _dataStore!.readingSessions.where( - (s) => s.startTime.isAfter(dayStart.subtract(const Duration(seconds: 1))) && s.startTime.isBefore(dayEnd), + final start = _activityPeriod == ActivityPeriod.week + ? DateTime(now.year, now.month, now.day - now.weekday + 1 + offset * 7) + : DateTime(now.year, now.month + offset); + final count = _activityPeriod == ActivityPeriod.week ? 7 : DateTime(start.year, start.month + 1, 0).day; + return List.generate(count, (i) { + final day = DateTime(start.year, start.month, start.day + i); + final end = DateTime(day.year, day.month, day.day + 1); + final totals = _dataStore!.activityTotals(day, end); + return DailyActivity( + date: day, + readingMinutes: totals.seconds ~/ 60, + pagesRead: totals.pages.floor(), + booksRead: totals.activities.where((a) => a.kind == 'completion').map((a) => a.bookId).toSet().toList(), ); - - final minutes = daySessions.fold(0, (sum, s) => sum + s.durationMinutes); - final pages = daySessions.fold(0, (sum, s) => sum + (s.pagesRead ?? 0)); - - return DailyActivity(date: date, readingMinutes: minutes, pagesRead: pages, booksRead: []); }); } diff --git a/app/lib/providers/goals_provider.dart b/app/lib/providers/goals_provider.dart index 2bf3c4db..6271d37d 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -1,82 +1,110 @@ +import 'package:papyrus/services/reading_device_identity.dart'; +import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/goal_period_record.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; -/// Provider for goals page state management. -/// Uses DataStore as the single source of truth. class GoalsProvider extends ChangeNotifier { - DataStore? _dataStore; - - // Loading state - bool _isLoading = false; + GoalsProvider({DateTime Function()? now, this.watchClock = true}) : _now = now ?? DateTime.now; + final bool watchClock; + final DateTime Function() _now; + DataStore? _store; + bool _sealing = false; String? _error; - - /// Attach to a DataStore instance. + Timer? _clock; + DataStore get store => _store!; + String? get error => _error; + bool get isLoading => _store?.isLoaded != true; + DateTime get now => _now().toUtc(); void attach(DataStore dataStore) { - if (_dataStore != dataStore) { - _dataStore?.removeListener(_onDataStoreChanged); - _dataStore = dataStore; - _dataStore!.addListener(_onDataStoreChanged); - notifyListeners(); - } + if (identical(_store, dataStore)) return; + _store?.removeListener(_changed); + _store = dataStore; + dataStore.addListener(_changed); + if (watchClock) _clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _changed()); + _changed(); } - void _onDataStoreChanged() { + void _changed() { notifyListeners(); + if (!_sealing && _store?.isLoaded == true) unawaited(_sealPeriods()); } - @override - void dispose() { - _dataStore?.removeListener(_onDataStoreChanged); - super.dispose(); - } - - // ============================================================================ - // GETTERS - // ============================================================================ - - bool get isLoading => _isLoading; - String? get error => _error; - - /// Get active goals from DataStore. - List get activeGoals { - if (_dataStore == null) return []; - return _dataStore!.activeGoals; - } - - /// Get completed/archived goals from DataStore. - List get completedGoals { - if (_dataStore == null) return []; - return _dataStore!.completedGoals; + List get current => store.goalDefinitions + .where((goal) => !goal.isArchived) + .map((goal) => projectGoal(goal, store.readingActivities, now)) + .where((progress) => progress.range.end.isAfter(now)) + .toList(); + List get activeGoals => current.map((progress) => progress.projected).toList(); + List get completedGoals => store.goalDefinitions.where((goal) => goal.isArchived).toList(); + bool get hasActiveGoals => current.isNotEmpty; + bool get hasCompletedGoals => store.goalPeriods.isNotEmpty || completedGoals.isNotEmpty; + GoalProgress progress(ReadingGoal goal) => projectGoal(goal, store.readingActivities, now); + List get history { + final all = {}; + for (final goal in store.goalDefinitions) { + for (final period in GoalCalendar.pastPeriods(goal, now)) { + all['${goal.id}:${period.start}'] = projectGoal(goal, store.readingActivities, now, period: period); + } + } + for (final record in store.goalPeriods) { + all['${record.goalId}:${record.definition.startDate}'] = projectGoal( + store.getReadingGoal(record.goalId) ?? record.definition, + store.readingActivities, + now, + period: GoalRange(record.definition.startDate, record.definition.endDate), + ); + } + final values = all.values.toList()..sort((a, b) => b.range.end.compareTo(a.range.end)); + return values; } - bool get hasActiveGoals => activeGoals.isNotEmpty; - bool get hasCompletedGoals => completedGoals.isNotEmpty; - - // ============================================================================ - // METHODS - // ============================================================================ - - /// Loads all goals data. With DataStore, this is mainly for loading state UX. - Future loadGoals() async { - _isLoading = true; - _error = null; - notifyListeners(); - + Future _sealPeriods() async { + _sealing = true; + final target = store.trackingRepository; try { - // Simulate network delay for realistic UX - await Future.delayed(const Duration(milliseconds: 100)); - - _isLoading = false; - notifyListeners(); - } catch (e) { - _error = 'Failed to load goals: $e'; - _isLoading = false; - notifyListeners(); + final known = store.goalPeriods.map((value) => value.id).toSet(); + final records = []; + for (final goal in store.goalDefinitions) { + for (final period in GoalCalendar.pastPeriods(goal, now)) { + final id = const Uuid().v5( + Namespace.url.value, + 'papyrus:goal-period:${goal.id}:${period.start.microsecondsSinceEpoch}', + ); + if (known.contains(id)) continue; + final rule = ruleAt(goal, period.end.subtract(const Duration(microseconds: 1))); + final definition = goal.copyWith( + startDate: period.start, + endDate: period.end, + targetValue: rule.target, + title: rule.title, + isActive: rule.active, + isArchived: rule.archived, + isRecurring: false, + rules: goal.rules.where((rule) => rule.at.isBefore(period.end)).toList(), + ); + records.add(GoalPeriodRecord(id: id, goalId: goal.id, definition: definition)); + } + } + if (records.isNotEmpty) await store.commitTracking(periods: records, repository: target); + _error = null; + } catch (error) { + _error = 'Could not save goal history: $error'; + } finally { + _sealing = false; } } - /// Creates a new goal. Persists to DataStore. + Future loadGoals() => store.waitUntilLoaded(); + Future refresh() => _sealPeriods(); Future createGoal({ required GoalType type, required int target, @@ -84,126 +112,215 @@ class GoalsProvider extends ChangeNotifier { bool isRecurring = true, DateTime? startDate, DateTime? endDate, + String? title, + GoalScope scope = GoalScope.library, + String? scopeId, + int minimumMinutes = 5, + String? timezone, + TrackingRepository? repository, }) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); - } - - final now = DateTime.now(); - - // Calculate dates based on period or use provided custom dates - final DateTime goalStartDate; - final DateTime goalEndDate; - - if (period == GoalPeriod.custom && startDate != null && endDate != null) { - goalStartDate = startDate; - goalEndDate = endDate; - } else { - goalStartDate = _getStartDateForPeriod(period, now); - goalEndDate = _getEndDateForPeriod(period, now); - } - - final newGoal = ReadingGoal( - id: 'goal-${DateTime.now().millisecondsSinceEpoch}', + if (target < 1 || minimumMinutes < 1 || minimumMinutes > 1440) throw ArgumentError('Targets must be positive.'); + if (scope != GoalScope.library && scopeId == null) throw ArgumentError('Choose a book or shelf.'); + final zone = timezone ?? await GoalCalendar.deviceTimezone(); + final created = now; + final range = period == GoalPeriod.custom + ? GoalRange(startDate ?? created, endDate ?? GoalCalendar.nextDay(created.add(const Duration(days: 30)), zone)) + : GoalCalendar.calendarPeriod(period, created, zone); + if (!range.end.isAfter(created)) throw ArgumentError('The deadline must be in the future.'); + final goal = ReadingGoal( + id: const Uuid().v4(), type: type, targetValue: target, - currentValue: 0, period: period, - startDate: goalStartDate, - endDate: goalEndDate, - isRecurring: isRecurring, + startDate: range.start, + endDate: range.end, + createdAt: created, + title: title?.trim().isEmpty == true ? null : title, + timezone: zone, + scope: scope, + scopeId: scopeId, + minimumMinutes: minimumMinutes, + isRecurring: period != GoalPeriod.custom && isRecurring, + rules: [GoalRule(at: created, target: target, title: title?.trim().isEmpty == true ? null : title)], ); - - _dataStore!.addReadingGoal(newGoal); + await store.commitTracking(goals: [goal], repository: repository); } - /// Deletes a goal by ID. Persists to DataStore. - Future deleteGoal(String goalId) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); - } - _dataStore!.deleteReadingGoal(goalId); + Future updateGoal({ + required String goalId, + int? target, + String? title, + GoalType? type, + TrackingRepository? repository, + }) async { + final goal = store.getReadingGoal(goalId); + if (goal == null) throw StateError('This goal no longer exists.'); + if (type != null && type != goal.type) throw StateError('Create a replacement to change the metric.'); + if (target != null && target < 1) throw ArgumentError('Target must be positive.'); + await _revise(goal, target: target, title: title, repository: repository); } - /// Updates the progress of a goal. Persists to DataStore. - Future updateGoalProgress(String goalId, int newProgress) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); - } - - final goal = _dataStore!.getReadingGoal(goalId); - if (goal != null) { - var updatedGoal = goal.copyWith(currentValue: newProgress); - - // Check if goal is now completed - if (updatedGoal.isCompleted && !goal.isArchived) { - updatedGoal = updatedGoal.copyWith(completedAt: DateTime.now(), isArchived: true); - } - - _dataStore!.updateReadingGoal(updatedGoal); - } + Future _revise( + ReadingGoal goal, { + int? target, + String? title, + bool? active, + bool? archived, + TrackingRepository? repository, + }) async { + var at = now; + final rules = goal.rules.isEmpty + ? [ + GoalRule( + at: goal.createdAt, + target: goal.targetValue, + title: goal.title, + active: goal.isActive, + archived: goal.isArchived, + ), + ] + : [...goal.rules]; + if (!at.isAfter(rules.last.at)) at = rules.last.at.add(const Duration(microseconds: 1)); + final rule = GoalRule( + at: at, + target: target ?? goal.targetValue, + title: title ?? goal.title, + active: active ?? goal.isActive, + archived: archived ?? goal.isArchived, + ); + await store.commitTracking( + goals: [ + goal.copyWith( + targetValue: rule.target, + title: rule.title, + isActive: rule.active, + isArchived: rule.archived, + rules: [...rules, rule], + ), + ], + repository: repository, + ); } - /// Updates a goal's properties. Persists to DataStore. - Future updateGoal({required String goalId, int? target, GoalType? type}) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); - } - - final goal = _dataStore!.getReadingGoal(goalId); - if (goal != null) { - _dataStore!.updateReadingGoal(goal.copyWith(targetValue: target ?? goal.targetValue, type: type ?? goal.type)); - } + Future pauseGoal(String id, bool paused, {TrackingRepository? repository}) => + _revise(store.getReadingGoal(id)!, active: !paused, repository: repository); + Future archiveGoal(String id, {TrackingRepository? repository}) => + _revise(store.getReadingGoal(id)!, archived: true, active: false, repository: repository); + Future restoreGoal(String id, {TrackingRepository? repository}) => + _revise(store.getReadingGoal(id)!, archived: false, active: true, repository: repository); + Future deleteGoal(String id, {TrackingRepository? repository}) async { + await _sealPeriods(); + final goal = store.getReadingGoal(id); + if (goal == null) return; + final period = GoalCalendar.currentPeriod(goal, now); + final record = GoalPeriodRecord( + id: const Uuid().v5(Namespace.url.value, 'papyrus:goal-period:$id:${period.start.microsecondsSinceEpoch}'), + goalId: id, + definition: goal.copyWith( + startDate: period.start, + endDate: now.isBefore(period.end) ? now : period.end, + isRecurring: false, + isArchived: true, + isActive: false, + rules: [ + ...goal.rules, + GoalRule( + at: goal.rules.isNotEmpty && !now.isAfter(goal.rules.last.at) + ? goal.rules.last.at.add(const Duration(microseconds: 1)) + : now, + target: goal.targetValue, + title: goal.title, + active: false, + archived: true, + ), + ], + ), + ); + await store.commitTracking(periods: [record], deleteGoalId: id, repository: repository); } - /// Archives (completes) a goal. Persists to DataStore. - Future archiveGoal(String goalId) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); + Future logReading({ + required Book book, + required DateTime end, + int minutes = 0, + int pages = 0, + bool finished = false, + String? note, + ReadingActivity? correcting, + TrackingRepository? repository, + }) async { + if (end.isAfter(now) || minutes < 0 || pages < 0 || minutes == 0 && pages == 0 && !finished) { + throw ArgumentError('Choose a past time and enter reading time, pages, or completion.'); } - - final goal = _dataStore!.getReadingGoal(goalId); - if (goal != null) { - _dataStore!.updateReadingGoal(goal.copyWith(isArchived: true, completedAt: DateTime.now())); + final created = now; + final shelfIds = correcting?.shelfIds ?? store.getShelfIdsForBook(book.id); + final activities = [ + if (correcting != null) store.reversalFor(correcting, note: 'Corrected entry'), + ]; + if (minutes > 0 || pages > 0) { + activities.add( + ReadingActivity( + id: const Uuid().v4(), + bookId: book.id, + bookTitle: book.title, + startTime: end.subtract(Duration(minutes: minutes)), + endTime: end, + createdAt: created, + deviceId: ReadingDeviceIdentity.current, + pages: pages, + shelfIds: shelfIds, + note: note, + ), + ); } + if (finished) { + activities.add( + ReadingActivity( + id: const Uuid().v4(), + bookId: book.id, + bookTitle: book.title, + startTime: end, + endTime: end, + createdAt: created, + deviceId: ReadingDeviceIdentity.current, + kind: 'completion', + shelfIds: shelfIds, + note: note, + ), + ); + } + final updated = finished + ? book.copyWith(readingStatus: LibraryReadingStatus.completed, completedAt: end) + : correcting?.kind == 'completion' + ? book.copyWith(readingStatus: LibraryReadingStatus.inProgress, clearCompletedAt: true) + : book.copyWith(lastReadAt: book.lastReadAt != null && book.lastReadAt!.isAfter(end) ? book.lastReadAt : end); + await store.commitTracking(activities: activities, book: updated, previousBook: book, repository: repository); } - /// Refreshes goals data. - Future refresh() async { - await loadGoals(); - } - - // ============================================================================ - // PRIVATE HELPERS - // ============================================================================ - - DateTime _getStartDateForPeriod(GoalPeriod period, DateTime now) { - switch (period) { - case GoalPeriod.daily: - return DateTime(now.year, now.month, now.day); - case GoalPeriod.weekly: - return now.subtract(Duration(days: now.weekday - 1)); - case GoalPeriod.monthly: - return DateTime(now.year, now.month, 1); - case GoalPeriod.yearly: - return DateTime(now.year, 1, 1); - case GoalPeriod.custom: - return now; // Should be provided by caller - } + Future reverseActivity(ReadingActivity original, {TrackingRepository? repository}) async { + final book = store.getBook(original.bookId); + final anotherCompletion = store.effectiveReadingActivities.any( + (activity) => activity.bookId == original.bookId && activity.kind == 'completion' && activity.id != original.id, + ); + await store.commitTracking( + activities: [ + for (final entry in store.effectiveReadingActivities.where( + (a) => a.id == original.id || original.constituentIds.contains(a.id), + )) + store.reversalFor(entry, note: 'Undone from activity history'), + ], + book: original.kind == 'completion' && book?.readingStatus == LibraryReadingStatus.completed && !anotherCompletion + ? book!.copyWith(readingStatus: LibraryReadingStatus.inProgress, clearCompletedAt: true) + : null, + previousBook: book, + repository: repository, + ); } - DateTime _getEndDateForPeriod(GoalPeriod period, DateTime now) { - switch (period) { - case GoalPeriod.daily: - return DateTime(now.year, now.month, now.day, 23, 59, 59); - case GoalPeriod.weekly: - return now.add(Duration(days: 7 - now.weekday)); - case GoalPeriod.monthly: - return DateTime(now.year, now.month + 1, 0); - case GoalPeriod.yearly: - return DateTime(now.year, 12, 31); - case GoalPeriod.custom: - return now.add(const Duration(days: 30)); // Should be provided by caller - } + @override + void dispose() { + _clock?.cancel(); + _store?.removeListener(_changed); + super.dispose(); } } diff --git a/app/lib/providers/statistics_provider.dart b/app/lib/providers/statistics_provider.dart index b609db54..00dde64f 100644 --- a/app/lib/providers/statistics_provider.dart +++ b/app/lib/providers/statistics_provider.dart @@ -3,6 +3,10 @@ import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/daily_activity.dart'; import 'package:papyrus/models/genre_stats.dart'; import 'package:papyrus/models/reading_streak.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/goal_progress.dart'; /// Time period for statistics filtering. enum StatsPeriod { week, month, year, allTime, custom } @@ -135,20 +139,33 @@ class StatisticsProvider extends ChangeNotifier { int get totalBooks { if (_dataStore == null) return 0; final range = _getDateRangeForPeriod(); - return _dataStore!.books - .where( - (b) => b.completedAt != null && b.completedAt!.isAfter(range.start) && b.completedAt!.isBefore(range.end), - ) - .length; + return _dataStore!.activityTotals(range.start, range.end).finishedBooks; } /// Goals completed in the selected period. int get goalsCompleted { if (_dataStore == null) return 0; final range = _getDateRangeForPeriod(); - return _dataStore!.readingGoals + final now = DateTime.now().toUtc(); + final periods = {}; + for (final record in _dataStore!.goalPeriods) { + periods['${record.goalId}:${record.definition.startDate.microsecondsSinceEpoch}'] = record.definition; + } + for (final goal in _dataStore!.goalDefinitions) { + for (final period in GoalCalendar.pastPeriods(goal, now)) { + periods['${goal.id}:${period.start.microsecondsSinceEpoch}'] = goal.copyWith( + startDate: period.start, + endDate: period.end, + isRecurring: false, + ); + } + } + return periods.values .where( - (g) => g.completedAt != null && g.completedAt!.isAfter(range.start) && g.completedAt!.isBefore(range.end), + (goal) => + !goal.endDate.isBefore(range.start) && + goal.endDate.isBefore(range.end) && + projectGoal(goal, _dataStore!.readingActivities, now).reached, ) .length; } @@ -157,18 +174,14 @@ class StatisticsProvider extends ChangeNotifier { int get totalReadingMinutes { if (_dataStore == null) return 0; final range = _getDateRangeForPeriod(); - return _dataStore!.readingSessions - .where((s) => s.startTime.isAfter(range.start) && s.startTime.isBefore(range.end)) - .fold(0, (sum, s) => sum + s.durationMinutes); + return _dataStore!.activityTotals(range.start, range.end).seconds ~/ 60; } /// Pages read in the selected period. int get pagesRead { if (_dataStore == null) return 0; final range = _getDateRangeForPeriod(); - return _dataStore!.readingSessions - .where((s) => s.startTime.isAfter(range.start) && s.startTime.isBefore(range.end)) - .fold(0, (sum, s) => sum + (s.pagesRead ?? 0)); + return _dataStore!.activityTotals(range.start, range.end).pages.floor(); } /// Session statistics for the selected period. @@ -177,15 +190,12 @@ class StatisticsProvider extends ChangeNotifier { return const SessionStats(totalSessions: 0, totalMinutes: 0, totalPages: 0); } final range = _getDateRangeForPeriod(); - final sessions = _dataStore!.readingSessions - .where((s) => s.startTime.isAfter(range.start) && s.startTime.isBefore(range.end)) - .toList(); - - return SessionStats( - totalSessions: sessions.length, - totalMinutes: sessions.fold(0, (sum, s) => sum + s.durationMinutes), - totalPages: sessions.fold(0, (sum, s) => sum + (s.pagesRead ?? 0)), + final sessions = groupReadingActivities( + _dataStore!.effectiveReadingActivities.where( + (s) => s.kind == 'reading' && s.endTime.isAfter(range.start) && s.startTime.isBefore(range.end), + ), ); + return SessionStats(totalSessions: sessions.length, totalMinutes: totalReadingMinutes, totalPages: pagesRead); } List get readingTimeData => _readingTimeData; @@ -320,60 +330,87 @@ class StatisticsProvider extends ChangeNotifier { final now = DateTime.now(); switch (_selectedPeriod) { case StatsPeriod.week: - final weekStart = now.subtract(Duration(days: now.weekday - 1)); - return (start: DateTime(weekStart.year, weekStart.month, weekStart.day), end: now.add(const Duration(days: 1))); + final weekStart = DateTime(now.year, now.month, now.day - now.weekday + 1); + return ( + start: DateTime(weekStart.year, weekStart.month, weekStart.day), + end: DateTime(now.year, now.month, now.day + 1), + ); case StatsPeriod.month: - return (start: DateTime(now.year, now.month, 1), end: now.add(const Duration(days: 1))); + return (start: DateTime(now.year, now.month, 1), end: DateTime(now.year, now.month, now.day + 1)); case StatsPeriod.year: - return (start: DateTime(now.year, 1, 1), end: now.add(const Duration(days: 1))); + return (start: DateTime(now.year, 1, 1), end: DateTime(now.year, now.month, now.day + 1)); case StatsPeriod.allTime: - return (start: DateTime(2000), end: now.add(const Duration(days: 1))); + return (start: DateTime(2000), end: DateTime(now.year, now.month, now.day + 1)); case StatsPeriod.custom: if (_customStartDate != null && _customEndDate != null) { - return (start: _customStartDate!, end: _customEndDate!.add(const Duration(days: 1))); + return ( + start: _customStartDate!, + end: DateTime(_customEndDate!.year, _customEndDate!.month, _customEndDate!.day + 1), + ); } - return (start: now.subtract(const Duration(days: 7)), end: now.add(const Duration(days: 1))); + return (start: DateTime(now.year, now.month, now.day - 7), end: DateTime(now.year, now.month, now.day + 1)); } } void _updateDataForPeriod() { - // Genre distribution (constant for now) - _genreDistribution = GenreStats.sample; - - // Streak - _streak = ReadingStreak.sample; - - // Monthly stats + final books = _dataStore?.books ?? []; + final genres = {}; + for (final book in books) { + final genre = book.customMetadata?['genre']; + if (genre is String && genre.trim().isNotEmpty) genres.update(genre.trim(), (n) => n + 1, ifAbsent: () => 1); + } + final count = genres.values.fold(0, (a, b) => a + b); + _genreDistribution = genres.entries + .map((e) => GenreStats(genre: e.key, bookCount: e.value, percentage: e.value / count)) + .toList(); + final now = DateTime.now(); + final qualified = _dataStore?.activityTotals(DateTime.utc(1900), now.toUtc()).qualifiedDays.toList() ?? []; + qualified.sort(); + var best = 0; + var run = 0; + DateTime? previous; + for (final day in qualified) { + run = previous != null && GoalCalendar.nextDay(previous, GoalCalendar.systemTimezone) == day ? run + 1 : 1; + if (run > best) best = run; + previous = day; + } + final days = qualified.toSet(); + var cursor = GoalCalendar.midnight(now, GoalCalendar.systemTimezone); + if (!days.contains(cursor)) cursor = GoalCalendar.dayOffset(cursor, -1, GoalCalendar.systemTimezone); + var current = 0; + while (days.contains(cursor)) { + current++; + cursor = GoalCalendar.dayOffset(cursor, -1, GoalCalendar.systemTimezone); + } + final local = GoalCalendar.local(now, GoalCalendar.systemTimezone); + _streak = ReadingStreak( + currentStreak: current, + bestStreak: best, + daysThisMonth: qualified.where((d) { + final day = GoalCalendar.local(d, GoalCalendar.systemTimezone); + return day.year == local.year && day.month == local.month; + }).length, + totalDaysInMonth: DateTime(local.year, local.month + 1, 0).day, + ); _monthlyStats = _generateMonthlyStats(); - - // Daily activity data _readingTimeData = _generateActivityData(); _pagesReadData = _readingTimeData; } List _generateActivityData() { - if (_dataStore == null) return DailyActivity.sampleWeek; - + if (_dataStore == null) return []; final range = _getDateRangeForPeriod(); - final days = range.end.difference(range.start).inDays; - - // Limit to 60 days for performance - final dataPoints = days.clamp(1, 60); - - return List.generate(dataPoints, (i) { - final date = range.start.add(Duration(days: i)); - final dayStart = DateTime(date.year, date.month, date.day); - final dayEnd = dayStart.add(const Duration(days: 1)); - - final daySessions = _dataStore!.readingSessions.where( - (s) => s.startTime.isAfter(dayStart.subtract(const Duration(seconds: 1))) && s.startTime.isBefore(dayEnd), - ); - + final end = DateTime(range.end.year, range.end.month, range.end.day); + final count = end.difference(range.start).inDays.clamp(1, 60); + final first = DateTime(end.year, end.month, end.day - count); + return List.generate(count, (i) { + final day = DateTime(first.year, first.month, first.day + i); + final totals = _dataStore!.activityTotals(day, DateTime(day.year, day.month, day.day + 1)); return DailyActivity( - date: date, - readingMinutes: daySessions.fold(0, (sum, s) => sum + s.durationMinutes), - pagesRead: daySessions.fold(0, (sum, s) => sum + (s.pagesRead ?? 0)), - booksRead: [], + date: day, + readingMinutes: totals.seconds ~/ 60, + pagesRead: totals.pages.floor(), + booksRead: totals.activities.where((a) => a.kind == 'completion').map((a) => a.bookId).toSet().toList(), ); }); } @@ -381,39 +418,15 @@ class StatisticsProvider extends ChangeNotifier { List _generateMonthlyStats() { final now = DateTime.now(); return List.generate(12, (i) { - final month = ((now.month - 1 - i) % 12) + 1; - final year = now.year - ((now.month - 1 - i) < 0 ? 1 : 0); - - if (_dataStore == null) { - final seed = (i * 7 + 3) % 5; - return MonthlyStats( - month: month, - year: year, - booksRead: seed + 2, - pagesRead: (seed + 2) * 150, - readingMinutes: (seed + 2) * 120, - ); - } - - final monthStart = DateTime(year, month, 1); - final monthEnd = DateTime(year, month + 1, 1); - - final monthSessions = _dataStore!.readingSessions.where( - (s) => s.startTime.isAfter(monthStart.subtract(const Duration(seconds: 1))) && s.startTime.isBefore(monthEnd), - ); - - final booksCompleted = _dataStore!.books - .where( - (b) => b.completedAt != null && b.completedAt!.isAfter(monthStart) && b.completedAt!.isBefore(monthEnd), - ) - .length; - + final start = DateTime(now.year, now.month - i); + final end = DateTime(start.year, start.month + 1); + final totals = _dataStore?.activityTotals(start, end); return MonthlyStats( - month: month, - year: year, - booksRead: booksCompleted, - pagesRead: monthSessions.fold(0, (sum, s) => sum + (s.pagesRead ?? 0)), - readingMinutes: monthSessions.fold(0, (sum, s) => sum + s.durationMinutes), + month: start.month, + year: start.year, + booksRead: totals?.finishedBooks ?? 0, + pagesRead: totals?.pages.floor() ?? 0, + readingMinutes: (totals?.seconds ?? 0) ~/ 60, ); }).reversed.toList(); } diff --git a/app/lib/reader/reader_book_adapter.dart b/app/lib/reader/reader_book_adapter.dart index 307f3bba..f16ba620 100644 --- a/app/lib/reader/reader_book_adapter.dart +++ b/app/lib/reader/reader_book_adapter.dart @@ -43,16 +43,14 @@ final class ReaderBookAdapter { final position = _totalProgression(locator); final page = switch (locator) { - EpubReaderLocator(:final spineIndex) => spineIndex + 1, + EpubReaderLocator() => book.currentPage, PdfReaderLocator(:final pageIndex) => pageIndex + 1, }; final cfi = switch (locator) { EpubReaderLocator(:final cfi) => cfi, PdfReaderLocator() => null, }; - final status = position >= 1 - ? LibraryReadingStatus.completed - : position > 0 + final status = book.readingStatus == LibraryReadingStatus.unread && position > 0 ? LibraryReadingStatus.inProgress : book.readingStatus; @@ -63,7 +61,7 @@ final class ReaderBookAdapter { readingStatus: status, customMetadata: metadata, startedAt: book.startedAt ?? (position > 0 ? now : null), - completedAt: position >= 1 ? now : book.completedAt, + completedAt: book.completedAt, lastReadAt: now, ); } diff --git a/app/lib/reader/reader_session.dart b/app/lib/reader/reader_session.dart index c06eb258..8566a708 100644 --- a/app/lib/reader/reader_session.dart +++ b/app/lib/reader/reader_session.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:papyrus/reader/reading_activity_tracker.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/reader/reader_book_adapter.dart'; @@ -11,12 +12,14 @@ final class ReaderSession { ReaderSession({ required Book book, required BookSaveCallback saveBook, + this.tracker, this.debounceDuration = const Duration(milliseconds: 500), ReaderClock? now, }) : _book = book, _saveBook = saveBook, _now = now ?? DateTime.now; + final ReadingActivityTracker? tracker; final BookSaveCallback _saveBook; final ReaderClock _now; final Duration debounceDuration; @@ -30,17 +33,25 @@ final class ReaderSession { if (_disposed) return; _pendingLocator = locator; + tracker?.updateLocator(locator); _timer?.cancel(); - _timer = Timer(debounceDuration, flush); + _timer = Timer(debounceDuration, () => unawaited(flush())); } - void flush() { + Future flush() async { final locator = _pendingLocator; - if (locator == null) return; + if (locator == null) { + await tracker?.flush(); + return; + } _timer?.cancel(); _timer = null; _pendingLocator = null; + if (tracker != null) { + await tracker!.flush(); + return; + } _book = ReaderBookAdapter.applyLocator(_book, locator, now: _now()); _saveBook(_book); } @@ -48,7 +59,8 @@ final class ReaderSession { void dispose() { if (_disposed) return; - flush(); + unawaited(flush()); + unawaited(tracker?.close().catchError((Object error) => tracker!.onError(error))); _disposed = true; } } diff --git a/app/lib/reader/reading_activity_tracker.dart b/app/lib/reader/reading_activity_tracker.dart new file mode 100644 index 00000000..5ebb541c --- /dev/null +++ b/app/lib/reader/reading_activity_tracker.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:uuid/uuid.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +/// Checkpoints are durable intervals; failures retry the same IDs, never increments. +class ReadingActivityTracker { + ReadingActivityTracker({ + required this.repository, + required this.book, + required List shelfIds, + required this.deviceId, + required this.onError, + DateTime Function()? now, + this.checkpointInterval = const Duration(seconds: 10), + this.exposureThreshold = const Duration(seconds: 10), + }) : _now = now ?? DateTime.now, + shelfIds = List.of(shelfIds) { + _timer = Timer.periodic(checkpointInterval, (_) => unawaited(flush())); + } + final TrackingRepository repository; + final Book book; + List shelfIds; + final String deviceId; + final void Function(Object) onError; + final DateTime Function() _now; + final Duration checkpointInterval; + final Duration exposureThreshold; + final String sessionId = const Uuid().v4(); + Timer? _timer; + ReaderActivityEvent? _event; + ReaderLocator? _locator; + bool _foreground = true; + bool _disposed = false; + DateTime? _started; + DateTime? _exposedSince; + String _coverageKey = ''; + final List _pending = []; + Map? _pendingPatch; + Future _writes = Future.value(); + bool get _active => + !_disposed && _foreground && _event?.ready == true && _event?.visible == true && repository.isCurrent; + + void onActivity(ReaderActivityEvent event) { + final wasActive = _active; + final now = _now().toUtc(); + final key = jsonEncode(event.coverage.map((c) => [c.key, c.start, c.end, c.chapterCount]).toList()); + final changed = key != _coverageKey; + if (changed || wasActive && (!event.ready || !event.visible)) _capture(now); + _event = event; + _coverageKey = key; + if (changed) _exposedSince = null; + if (!_active) { + _started = null; + _exposedSince = null; + } else { + _started ??= now; + _exposedSince ??= now; + } + if (event.locator != null) updateLocator(event.locator!); + if (changed || wasActive != _active) unawaited(_drain()); + } + + void updateScope(List ids) { + if (_disposed || ids.length == shelfIds.length && ids.every(shelfIds.contains)) return; + _capture(_now().toUtc()); + shelfIds = List.of(ids); + unawaited(_drain()); + } + + void setForeground(bool foreground) { + if (_foreground == foreground) return; + _capture(_now().toUtc()); + _foreground = foreground; + _started = _active ? _now().toUtc() : null; + _exposedSince = _started; + unawaited(_drain()); + } + + void updateLocator(ReaderLocator locator) { + _locator = locator; + final position = switch (locator) { + EpubReaderLocator(:final totalProgression) => totalProgression, + PdfReaderLocator(:final totalProgression) => totalProgression, + }; + _pendingPatch = { + 'reader_locator': locator.toJson(), + 'current_position': position, + 'last_read_at': _now().toUtc().toIso8601String(), + if (locator is PdfReaderLocator) 'current_page': locator.pageIndex + 1, + if (locator is EpubReaderLocator) 'current_cfi': locator.cfi, + }; + } + + void _capture(DateTime now) { + final start = _started; + if (start == null || !_active || !now.isAfter(start)) return; + final coverage = []; + if (_exposedSince != null && now.difference(_exposedSince!) >= exposureThreshold) { + for (final extent in _event!.coverage) { + if (extent.end <= extent.start) continue; + final chapterCount = extent.chapterCount; + final pageCount = book.pageCount; + if (chapterCount != null && (pageCount == null || pageCount <= 0)) continue; + coverage.add( + PageCoverage( + key: '${book.id}:${extent.key}', + start: extent.start, + end: extent.end, + pagesPerUnit: chapterCount == null ? 1 : pageCount! / chapterCount, + estimated: chapterCount != null, + ), + ); + } + } + _pending.add( + ReadingActivity( + id: const Uuid().v4(), + bookId: book.id, + bookTitle: book.title, + startTime: start, + endTime: now, + createdAt: now, + source: 'reader', + sessionId: sessionId, + deviceId: deviceId, + shelfIds: shelfIds, + coverage: coverage, + ), + ); + _started = now; + final locator = _locator; + if (locator != null) updateLocator(locator); + } + + Future flush() { + _capture(_now().toUtc()); + return _drain(); + } + + Future _drain() { + final operation = _writes.then((_) async { + if (!repository.isCurrent || _pending.isEmpty && _pendingPatch == null) return; + final batch = List.from(_pending); + final patch = _pendingPatch; + try { + await repository.commitTracking( + activities: batch, + readerBookId: patch == null ? null : book.id, + readerPatch: patch, + ); + _pending.removeWhere((activity) => batch.any((saved) => saved.id == activity.id)); + if (identical(_pendingPatch, patch)) _pendingPatch = null; + } catch (error) { + onError(error); + } + }); + _writes = operation; + return operation; + } + + Future close() async { + _timer?.cancel(); + _capture(_now().toUtc()); + _disposed = true; + await _drain(); + if (repository.isCurrent && (_pending.isNotEmpty || _pendingPatch != null)) { + _disposed = false; + _started = _active ? _now().toUtc() : null; + _timer = Timer.periodic(checkpointInterval, (_) => unawaited(flush())); + throw StateError('Reading activity is still pending a local write.'); + } + } +} diff --git a/app/lib/services/reading_device_identity.dart b/app/lib/services/reading_device_identity.dart new file mode 100644 index 00000000..85e50fa3 --- /dev/null +++ b/app/lib/services/reading_device_identity.dart @@ -0,0 +1,19 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +/// An installation identity shared by reader, manual, and correction records. +abstract final class ReadingDeviceIdentity { + static const preferenceKey = 'reading_device_id'; + static String _value = const Uuid().v4(); + + static String get current => _value; + + static Future initialize(SharedPreferences preferences) async { + final stored = preferences.getString(preferenceKey); + if (stored != null && stored.isNotEmpty) { + _value = stored; + } else { + await preferences.setString(preferenceKey, _value); + } + } +} diff --git a/app/lib/widgets/goals/active_goal_details_sheet.dart b/app/lib/widgets/goals/active_goal_details_sheet.dart deleted file mode 100644 index 91728777..00000000 --- a/app/lib/widgets/goals/active_goal_details_sheet.dart +++ /dev/null @@ -1,555 +0,0 @@ -import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; -import 'package:flutter/material.dart'; -import 'package:papyrus/models/reading_goal.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/themes/app_motion.dart'; -import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; - -/// Bottom sheet for viewing and editing an active goal. -class ActiveGoalDetailsSheet extends StatefulWidget { - /// The goal to display/edit. - final ReadingGoal goal; - - /// Called when progress is updated. - final void Function(int newProgress)? onUpdateProgress; - - /// Called when goal is edited. - final void Function(int newTarget)? onEdit; - - /// Called when goal is deleted. - final VoidCallback? onDelete; - - const ActiveGoalDetailsSheet({super.key, required this.goal, this.onUpdateProgress, this.onEdit, this.onDelete}); - - /// Shows the active goal details sheet. - static Future show( - BuildContext context, { - required ReadingGoal goal, - void Function(int newProgress)? onUpdateProgress, - void Function(int newTarget)? onEdit, - VoidCallback? onDelete, - }) { - return showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - isScrollControlled: true, - useSafeArea: true, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), - builder: (context) => - ActiveGoalDetailsSheet(goal: goal, onUpdateProgress: onUpdateProgress, onEdit: onEdit, onDelete: onDelete), - ); - } - - @override - State createState() => _ActiveGoalDetailsSheetState(); -} - -class _ActiveGoalDetailsSheetState extends State { - late TextEditingController _progressController; - late TextEditingController _targetController; - late int _currentProgress; - late int _currentTarget; - bool _isEditingProgress = false; - bool _isEditingTarget = false; - - @override - void initState() { - super.initState(); - _currentProgress = widget.goal.current; - _currentTarget = widget.goal.target; - _progressController = TextEditingController(text: _currentProgress.toString()); - _targetController = TextEditingController(text: _currentTarget.toString()); - } - - @override - void dispose() { - _progressController.dispose(); - _targetController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - return AppBottomSheet( - title: widget.goal.description, - onClose: () => Navigator.of(context).pop(), - footer: BottomSheetActions( - secondary: OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - _showDeleteConfirmation(context); - }, - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide(color: colorScheme.error), - ), - child: const Text('Delete'), - ), - primary: FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done')), - ), - body: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_getGoalTypeLabel(), style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.lg), - // Progress section - _buildProgressSection(context, colorScheme, textTheme), - const SizedBox(height: Spacing.lg), - - // Target section - _buildTargetSection(context, colorScheme, textTheme), - const SizedBox(height: Spacing.lg), - - // Goal info - Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - children: [ - _buildInfoRow(context, icon: _getRecurrenceIcon(), label: 'Type', value: widget.goal.recurrenceLabel), - const Divider(height: Spacing.lg), - _buildInfoRow(context, icon: Icons.date_range, label: 'Period', value: _getPeriodDescription()), - if (widget.goal.isDaily && widget.goal.isRecurring && widget.goal.streak > 0) ...[ - const Divider(height: Spacing.lg), - _buildInfoRow( - context, - icon: Icons.local_fire_department, - label: 'Current streak', - value: '${widget.goal.streak} days', - valueColor: colorScheme.tertiary, - ), - ], - ], - ), - ), - const SizedBox(height: Spacing.xl), - ], - ), - ); - } - - Widget _buildProgressSection(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - return Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text('Progress', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - if (!_isEditingProgress) - TextButton.icon( - onPressed: () => setState(() => _isEditingProgress = true), - icon: const Icon(Icons.edit, size: 16), - label: const Text('Update'), - style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - ), - ], - ), - const SizedBox(height: Spacing.sm), - if (_isEditingProgress) ...[ - if (widget.goal.type == GoalType.minutes) - _buildDurationStepper( - value: _currentProgress, - onChanged: (v) => setState(() => _currentProgress = v), - onSave: () { - widget.onUpdateProgress?.call(_currentProgress); - setState(() => _isEditingProgress = false); - }, - onCancel: () { - setState(() { - _currentProgress = widget.goal.current; - _isEditingProgress = false; - }); - }, - ) - else - Row( - children: [ - Expanded( - child: TextField( - controller: _progressController, - keyboardType: TextInputType.number, - autofocus: true, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.sm), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.sm)), - suffixText: 'of $_currentTarget', - ), - ), - ), - const SizedBox(width: Spacing.sm), - IconButton.filled(onPressed: _saveProgress, icon: const Icon(Icons.check, size: 20)), - const SizedBox(width: Spacing.xs), - IconButton.outlined( - onPressed: () { - setState(() { - _isEditingProgress = false; - _progressController.text = _currentProgress.toString(); - }); - }, - icon: const Icon(Icons.close, size: 20), - ), - ], - ), - ] else ...[ - Builder( - builder: (context) { - final progress = (_currentProgress / _currentTarget).clamp(0.0, 1.0); - final isCompleted = _currentProgress >= _currentTarget; - final progressLabel = '${(progress * 100).round()}%'; - - return Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.goal.type == GoalType.minutes - ? '${formatDuration(_currentProgress)} of ${formatDuration(_currentTarget)}' - : '$_currentProgress of $_currentTarget ${widget.goal.typeLabel}', - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: Spacing.xs), - ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.sm), - child: AppLinearProgressIndicator( - value: progress, - minHeight: 8, - backgroundColor: colorScheme.surfaceContainerHighest, - color: isCompleted ? colorScheme.tertiary : colorScheme.primary, - ), - ), - ], - ), - ), - const SizedBox(width: Spacing.md), - Text( - progressLabel, - style: textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - color: isCompleted ? colorScheme.tertiary : colorScheme.primary, - ), - ), - ], - ); - }, - ), - ], - ], - ), - ); - } - - Widget _buildTargetSection(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - return Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text('Target', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - if (!_isEditingTarget) - TextButton.icon( - onPressed: () => setState(() => _isEditingTarget = true), - icon: const Icon(Icons.edit, size: 16), - label: const Text('Edit'), - style: TextButton.styleFrom(visualDensity: VisualDensity.compact), - ), - ], - ), - const SizedBox(height: Spacing.sm), - if (_isEditingTarget) - widget.goal.type == GoalType.minutes - ? _buildDurationStepper( - value: _currentTarget, - minValue: 5, - onChanged: (v) => setState(() => _currentTarget = v), - onSave: () { - widget.onEdit?.call(_currentTarget); - setState(() => _isEditingTarget = false); - }, - onCancel: () { - setState(() { - _currentTarget = widget.goal.target; - _isEditingTarget = false; - }); - }, - ) - : SizedBox( - height: 36, - child: Row( - children: [ - Expanded( - child: TextField( - controller: _targetController, - keyboardType: TextInputType.number, - autofocus: true, - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.sm), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.sm)), - suffixText: widget.goal.typeLabel, - ), - ), - ), - const SizedBox(width: Spacing.sm), - IconButton.filled(onPressed: _saveTarget, icon: const Icon(Icons.check, size: 20)), - const SizedBox(width: Spacing.xs), - IconButton.outlined( - onPressed: () { - setState(() { - _isEditingTarget = false; - _targetController.text = _currentTarget.toString(); - }); - }, - icon: const Icon(Icons.close, size: 20), - ), - ], - ), - ) - else - SizedBox( - height: 36, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - widget.goal.type == GoalType.minutes - ? formatDuration(_currentTarget) - : '$_currentTarget ${widget.goal.typeLabel}', - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ), - ], - ), - ); - } - - Widget _buildInfoRow( - BuildContext context, { - required IconData icon, - required String label, - required String value, - Color? valueColor, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Row( - children: [ - Icon(icon, size: 20, color: colorScheme.onSurfaceVariant), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - value, - textAlign: TextAlign.end, - style: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: valueColor), - ), - ), - ], - ); - } - - Widget _buildDurationStepper({ - required int value, - required ValueChanged onChanged, - required VoidCallback onSave, - required VoidCallback onCancel, - int minValue = 0, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), - decoration: BoxDecoration( - border: Border.all(color: colorScheme.outline), - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: value > minValue - ? () { - final step = value > 60 ? 15 : 5; - onChanged((value - step).clamp(minValue, value)); - } - : null, - icon: const Icon(Icons.remove, size: 20), - visualDensity: VisualDensity.compact, - ), - Text(formatDuration(value), style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), - IconButton( - onPressed: () { - final step = value >= 60 ? 15 : 5; - onChanged(value + step); - }, - icon: const Icon(Icons.add, size: 20), - visualDensity: VisualDensity.compact, - ), - ], - ), - ), - ), - const SizedBox(width: Spacing.sm), - IconButton.filled(onPressed: onSave, icon: const Icon(Icons.check, size: 20)), - const SizedBox(width: Spacing.xs), - IconButton.outlined(onPressed: onCancel, icon: const Icon(Icons.close, size: 20)), - ], - ); - } - - void _saveProgress() { - final newProgress = int.tryParse(_progressController.text); - if (newProgress != null && newProgress >= 0) { - widget.onUpdateProgress?.call(newProgress); - setState(() { - _currentProgress = newProgress; - _isEditingProgress = false; - }); - } - } - - void _saveTarget() { - final newTarget = int.tryParse(_targetController.text); - if (newTarget != null && newTarget > 0) { - widget.onEdit?.call(newTarget); - setState(() { - _currentTarget = newTarget; - _isEditingTarget = false; - }); - } - } - - void _showDeleteConfirmation(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - showDialog( - animationStyle: AppMotion.animationStyle(context), - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete goal?'), - content: Text('This will permanently remove "${widget.goal.description}" from your goals.'), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.of(context).pop(); - widget.onDelete?.call(); - }, - style: FilledButton.styleFrom(backgroundColor: colorScheme.error), - child: const Text('Delete'), - ), - ], - ), - ); - } - - String _getGoalTypeLabel() { - if (widget.goal.isCustomPeriod) { - return 'Custom date range'; - } - final period = _getPeriodLabel(); - if (widget.goal.isRecurring) { - return 'Recurring $period goal'; - } - return 'One-off $period goal'; - } - - String _getPeriodLabel() { - switch (widget.goal.period) { - case GoalPeriod.daily: - return 'daily'; - case GoalPeriod.weekly: - return 'weekly'; - case GoalPeriod.monthly: - return 'monthly'; - case GoalPeriod.yearly: - return 'yearly'; - case GoalPeriod.custom: - return 'custom'; - } - } - - String _getPeriodDescription() { - if (widget.goal.isCustomPeriod) { - return '${_formatDate(widget.goal.startDate)} - ${_formatDate(widget.goal.endDate)}'; - } - switch (widget.goal.period) { - case GoalPeriod.daily: - return 'Today'; - case GoalPeriod.weekly: - return 'This week'; - case GoalPeriod.monthly: - return _formatMonthYear(widget.goal.startDate); - case GoalPeriod.yearly: - return '${widget.goal.startDate.year}'; - case GoalPeriod.custom: - return '${_formatDate(widget.goal.startDate)} - ${_formatDate(widget.goal.endDate)}'; - } - } - - String _formatDate(DateTime date) { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; - } - - String _formatMonthYear(DateTime date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return '${months[date.month - 1]} ${date.year}'; - } - - IconData _getRecurrenceIcon() { - if (widget.goal.isCustomPeriod) return Icons.date_range; - if (widget.goal.isRecurring) return Icons.repeat; - return Icons.looks_one; - } -} diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index eb7f06fe..e70d6d8e 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -1,466 +1,408 @@ -import 'package:papyrus/widgets/shared/sheet_choice_buttons.dart'; -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; import 'package:papyrus/models/reading_goal.dart'; -import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; -import 'package:papyrus/widgets/shared/app_motion_control.dart'; -/// The type of goal scheduling. -enum GoalScheduleType { - recurring, // Repeats periodically (daily, weekly, monthly, yearly) - oneOff, // Single occurrence with a preset period - custom, // Custom date range -} - -/// Bottom sheet for creating a new goal. class AddGoalSheet extends StatefulWidget { - /// Called when a goal is created. - final void Function( - GoalType type, - int target, - GoalPeriod period, - bool isRecurring, - DateTime? startDate, - DateTime? endDate, - )? - onCreate; - - const AddGoalSheet({super.key, this.onCreate}); - - /// Shows the add goal sheet. + const AddGoalSheet({super.key, required this.provider, this.editing, this.preset = 0, this.initialTimezone}); + final GoalsProvider provider; + final ReadingGoal? editing; + final int preset; + final String? initialTimezone; static Future show( BuildContext context, { - void Function( - GoalType type, - int target, - GoalPeriod period, - bool isRecurring, - DateTime? startDate, - DateTime? endDate, - )? - onCreate, - }) { - return showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - isScrollControlled: true, - useSafeArea: true, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), - builder: (context) => AddGoalSheet(onCreate: onCreate), - ); - } - + required GoalsProvider provider, + ReadingGoal? editing, + int preset = 0, + String? initialTimezone, + }) => showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + sheetAnimationStyle: AppMotion.animationStyle(context), + constraints: const BoxConstraints(maxWidth: 640), + builder: (_) => + AddGoalSheet(provider: provider, editing: editing, preset: preset, initialTimezone: initialTimezone), + ); @override State createState() => _AddGoalSheetState(); } class _AddGoalSheetState extends State { - GoalScheduleType _scheduleType = GoalScheduleType.recurring; - GoalType _selectedType = GoalType.books; - GoalPeriod _selectedPeriod = GoalPeriod.yearly; - final _targetController = TextEditingController(text: '12'); - int _durationMinutes = 30; + final _form = GlobalKey(); + final _target = TextEditingController(); + final _title = TextEditingController(); + final _threshold = TextEditingController(text: '5'); + final _zone = TextEditingController(text: 'UTC'); + late final TrackingRepository? _repository; + GoalType _type = GoalType.minutes; + GoalPeriod _period = GoalPeriod.daily; + GoalScope _scope = GoalScope.library; + String? _scopeId; + bool _recurring = true; + bool _saving = false; + bool _timezoneReady = false; + String? _error; + int _preset = 0; + DateTime _deadline = DateTime.now().add(const Duration(days: 30)); + @override + void initState() { + super.initState(); + _repository = widget.provider.store.trackingRepository; + _applyPreset(widget.preset); + final editing = widget.editing; + if (editing != null) { + _type = editing.type; + _period = editing.period; + _scope = editing.scope; + _scopeId = editing.scopeId; + _recurring = editing.isRecurring; + _target.text = '${editing.targetValue}'; + _title.text = editing.title ?? ''; + _threshold.text = '${editing.minimumMinutes}'; + _zone.text = editing.timezone; + _timezoneReady = true; + } else if (widget.initialTimezone != null) { + _zone.text = widget.initialTimezone!; + _timezoneReady = true; + } else { + GoalCalendar.deviceTimezone() + .then((zone) { + if (mounted) { + setState(() { + _zone.text = zone; + _timezoneReady = true; + }); + } + }) + .catchError((Object error) { + if (mounted) { + setState(() { + _timezoneReady = true; + _error = 'Could not detect your timezone. Check the timezone below before saving.'; + }); + } + }); + } + } - // Custom date range - DateTime _startDate = DateTime.now(); - DateTime _endDate = DateTime.now().add(const Duration(days: 30)); + void _applyPreset(int index) { + _preset = index; + _type = [GoalType.minutes, GoalType.days, GoalType.books, GoalType.pages, GoalType.books][index]; + _period = [GoalPeriod.daily, GoalPeriod.weekly, GoalPeriod.yearly, GoalPeriod.weekly, GoalPeriod.custom][index]; + _target.text = ['30', '5', '12', '100', '1'][index]; + _scope = index == 4 ? GoalScope.book : GoalScope.library; + _scopeId = null; + _recurring = index != 4; + } @override void dispose() { - _targetController.dispose(); + _target.dispose(); + _title.dispose(); + _threshold.dispose(); + _zone.dispose(); super.dispose(); } + String? _positive(String? value) { + final number = int.tryParse(value ?? ''); + return number == null || number < 1 || number > 100000 ? 'Enter a number from 1 to 100,000.' : null; + } + @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; + final editing = widget.editing != null; + final store = widget.provider.store; + final preview = ReadingGoal( + id: 'preview', + title: _title.text.isEmpty ? null : _title.text, + type: _type, + targetValue: int.tryParse(_target.text) ?? 0, + period: _period, + startDate: DateTime.now(), + endDate: _deadline, + isRecurring: _recurring, + ); + final items = _scope == GoalScope.book + ? {for (final book in store.books) book.id: book.title} + : {for (final shelf in store.shelves) shelf.id: shelf.name}; return AppBottomSheet( - title: 'Create new goal', - onClose: () => Navigator.of(context).pop(), - footer: BottomSheetFormActions( - onCancel: () => Navigator.of(context).pop(), - onSave: _onCreate, - saveLabel: 'Create goal', - ), - body: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Schedule type selection - Text('Goal type', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - SheetChoiceButtons( - segments: const [ - ButtonSegment( - value: GoalScheduleType.recurring, - label: Text('Recurring'), - icon: Icon(Icons.repeat, size: 18), - ), - ButtonSegment( - value: GoalScheduleType.oneOff, - label: Text('One-off'), - icon: Icon(Icons.looks_one, size: 18), - ), - ButtonSegment( - value: GoalScheduleType.custom, - label: Text('Custom'), - icon: Icon(Icons.date_range, size: 18), + title: editing ? 'Edit goal' : 'New goal', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!editing) ...[ + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + for (var i = 0; i < 5; i++) + ChoiceChip( + label: Text( + ['Daily time', 'Reading days', 'Books this year', 'Weekly pages', 'Finish a book'][i], + ), + selected: _preset == i, + onSelected: _saving ? null : (_) => setState(() => _applyPreset(i)), + ), + ], ), + const SizedBox(height: Spacing.lg), ], - selected: {_scheduleType}, - onSelectionChanged: (selected) { - setState(() => _scheduleType = selected.first); - }, - ), - const SizedBox(height: Spacing.md), - - // Description of selected type - Container( - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.sm), + TextFormField( + controller: _title, + maxLength: 255, + decoration: const InputDecoration(labelText: 'Name (optional)'), + onChanged: (_) => setState(() {}), ), - child: Row( - children: [ - Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - _getScheduleDescription(), - style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + const SizedBox(height: Spacing.md), + DropdownButtonFormField( + initialValue: _type, + key: ValueKey('metric-$_type'), + isExpanded: true, + decoration: const InputDecoration(labelText: 'Measure'), + items: [ + for (final type in GoalType.values) + DropdownMenuItem( + value: type, + child: Text( + { + GoalType.books: 'Books finished', + GoalType.pages: 'Pages read', + GoalType.minutes: 'Reading time (minutes)', + GoalType.days: 'Reading days', + }[type]!, + ), ), - ), ], + onChanged: editing || _saving + ? null + : (value) => setState(() { + _type = value!; + _preset = -1; + }), ), - ), - const SizedBox(height: Spacing.lg), - - // What to track - Text('What to track', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - DropdownButtonFormField( - isExpanded: true, - initialValue: _selectedType, - decoration: InputDecoration( - border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - ), - items: GoalType.values.map((type) { - return DropdownMenuItem( - value: type, - child: Text(_getTypeLabel(type), maxLines: 1, overflow: TextOverflow.ellipsis), - ); - }).toList(), - onChanged: (value) { - if (value != null) { - setState(() { - _selectedType = value; - _targetController.text = _getDefaultTarget(value); - if (value == GoalType.minutes) _durationMinutes = 30; - }); - } - }, - ), - const SizedBox(height: Spacing.lg), - - // Target - Text('Target', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - if (_selectedType == GoalType.minutes) - _buildDurationPicker(colorScheme, textTheme) - else + const SizedBox(height: Spacing.md), TextFormField( - controller: _targetController, + controller: _target, keyboardType: TextInputType.number, - decoration: InputDecoration( - border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppRadius.md)), - contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - suffixText: _getTypeSuffix(_selectedType), - ), - ), - const SizedBox(height: Spacing.lg), - - // Period selection (for recurring and one-off) - if (_scheduleType != GoalScheduleType.custom) ...[ - Text('Time period', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - SheetChoiceButtons( - segments: [GoalPeriod.daily, GoalPeriod.weekly, GoalPeriod.monthly, GoalPeriod.yearly].map((period) { - return ButtonSegment(value: period, label: Text(_getPeriodLabel(period))); - }).toList(), - selected: {_selectedPeriod}, - onSelectionChanged: (selected) { - setState(() => _selectedPeriod = selected.first); - }, + decoration: InputDecoration(labelText: 'Target (${preview.typeLabel})'), + validator: _positive, + onChanged: (_) => setState(() {}), ), - const SizedBox(height: Spacing.lg), - ], - - // Custom date range picker - if (_scheduleType == GoalScheduleType.custom) ...[ - Text('Date range', style: textTheme.titleSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.sm), - Row( - children: [ - Expanded( - child: _buildDateButton( - context, - label: 'Start', - date: _startDate, - onTap: () => _pickStartDate(context), + const SizedBox(height: Spacing.md), + DropdownButtonFormField( + initialValue: _period, + key: ValueKey('period-$_period'), + isExpanded: true, + decoration: const InputDecoration(labelText: 'Schedule'), + items: [ + for (final period in GoalPeriod.values) + DropdownMenuItem( + value: period, + child: Text( + { + GoalPeriod.daily: 'Daily', + GoalPeriod.weekly: 'Weekly', + GoalPeriod.monthly: 'Monthly', + GoalPeriod.yearly: 'Yearly', + GoalPeriod.custom: 'By a deadline', + }[period]!, + ), ), - ), - const SizedBox(width: Spacing.md), - Expanded( - child: _buildDateButton(context, label: 'End', date: _endDate, onTap: () => _pickEndDate(context)), - ), ], + onChanged: editing || _saving + ? null + : (value) => setState(() { + _period = value!; + if (_period == GoalPeriod.custom) _recurring = false; + }), ), - const SizedBox(height: Spacing.sm), - Text( - '${_daysBetween()} days total', - style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - const SizedBox(height: Spacing.lg), - ], - ], - ), - ); - } - - Widget _buildDurationPicker(ColorScheme colorScheme, TextTheme textTheme) { - const presets = [15, 30, 60, 120]; - const presetLabels = ['15m', '30m', '1h', '2h']; - - return Column( - children: [ - // Preset chips - Wrap( - spacing: Spacing.sm, - children: List.generate(presets.length, (i) { - return AppMotionControl( - value: null, - builder: (focusNode) => ChoiceChip( - focusNode: focusNode, - chipAnimationStyle: appChipAnimationStyle(context), - label: Text(presetLabels[i]), - selected: _durationMinutes == presets[i], - onSelected: (_) { - setState(() => _durationMinutes = presets[i]); - }, - ), - ); - }), - ), - const SizedBox(height: Spacing.md), - // Stepper row - Container( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), - decoration: BoxDecoration( - border: Border.all(color: colorScheme.outline), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - onPressed: _durationMinutes > 5 - ? () { - setState(() { - final step = _durationMinutes > 60 ? 15 : 5; - _durationMinutes = (_durationMinutes - step).clamp(5, _durationMinutes); - }); - } - : null, - icon: const Icon(Icons.remove), + if (_period == GoalPeriod.custom) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: OutlinedButton.icon( + onPressed: editing || _saving + ? null + : () async { + final date = await showAppDatePicker( + context: context, + initialDate: _deadline, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 3650)), + ); + if (date != null && mounted) setState(() => _deadline = date); + }, + icon: const Icon(Icons.event_outlined), + label: Text('Deadline: ${_deadline.day}/${_deadline.month}/${_deadline.year}'), + ), + ) + else + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Repeat each period'), + value: _recurring, + onChanged: editing || _saving ? null : (value) => setState(() => _recurring = value), ), - const SizedBox(width: Spacing.md), - Text( - formatDuration(_durationMinutes), - style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + const SizedBox(height: Spacing.md), + DropdownButtonFormField( + initialValue: _scope, + key: ValueKey('scope-$_scope'), + isExpanded: true, + decoration: const InputDecoration(labelText: 'Include'), + items: [ + for (final scope in GoalScope.values) + DropdownMenuItem( + value: scope, + child: Text( + { + GoalScope.library: 'Whole library', + GoalScope.book: 'A book', + GoalScope.shelf: 'A shelf', + }[scope]!, + ), + ), + ], + onChanged: editing || _saving + ? null + : (value) => setState(() { + _scope = value!; + _scopeId = null; + }), + ), + if (_scope != GoalScope.library) ...[ + const SizedBox(height: Spacing.md), + DropdownButtonFormField( + initialValue: items.containsKey(_scopeId) ? _scopeId : null, + key: ValueKey('scope-item-$_scope-$_scopeId'), + isExpanded: true, + decoration: InputDecoration(labelText: _scope == GoalScope.book ? 'Book' : 'Shelf'), + items: [ + for (final item in items.entries) + DropdownMenuItem( + value: item.key, + child: Text(item.value, overflow: TextOverflow.ellipsis), + ), + ], + validator: (value) => + value == null ? 'Choose ${_scope == GoalScope.book ? 'a book' : 'a shelf'}.' : null, + onChanged: editing || _saving ? null : (value) => setState(() => _scopeId = value), ), - const SizedBox(width: Spacing.md), - IconButton( - onPressed: () { - setState(() { - final step = _durationMinutes >= 60 ? 15 : 5; - _durationMinutes += step; - }); + ], + if (_type == GoalType.days) ...[ + const SizedBox(height: Spacing.md), + TextFormField( + controller: _threshold, + enabled: !editing, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Minimum minutes per reading day', + helperText: 'Time adds up throughout the day.', + ), + validator: (value) { + final n = int.tryParse(value ?? ''); + return n == null || n < 1 || n > 1440 ? 'Enter 1–1,440 minutes.' : null; }, - icon: const Icon(Icons.add), ), ], - ), - ), - ], - ); - } - - Widget _buildDateButton( - BuildContext context, { - required String label, - required DateTime date, - required VoidCallback onTap, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.md), - child: Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - border: Border.all(color: colorScheme.outline), - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)), - const SizedBox(height: Spacing.xs), - Row( - children: [ - Icon(Icons.calendar_today, size: 16, color: colorScheme.primary), - const SizedBox(width: Spacing.sm), - Text(_formatDate(date), style: textTheme.bodyMedium), - ], + const SizedBox(height: Spacing.md), + TextFormField( + controller: _zone, + enabled: !editing, + decoration: const InputDecoration( + labelText: 'Timezone', + helperText: 'Calendar periods follow this timezone on every device.', + ), + validator: (value) { + try { + GoalCalendar.location(value ?? ''); + return null; + } catch (_) { + return 'Use an IANA timezone, such as Europe/Vilnius.'; + } + }, + ), + const SizedBox(height: Spacing.lg), + Text(preview.displayTitle, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + Text( + editing + ? 'Past periods keep their original target. To change the measure, schedule, or scope, create a new goal.' + : 'Only reading after this goal is created counts. Progress comes from the reader and your manual logs.', + style: Theme.of(context).textTheme.bodyMedium, ), + if (_type == GoalType.pages) + const Padding( + padding: EdgeInsets.only(top: Spacing.sm), + child: Text('PDF pages are measured. EPUB pages are estimated when page-count metadata is available.'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), ], ), ), + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context), + onSave: _saving || !_timezoneReady ? null : _save, + saveLabel: _saving + ? 'Saving…' + : editing + ? 'Save' + : 'Create goal', + ), ); } - Future _pickStartDate(BuildContext context) async { - final picked = await showAppDatePicker( - context: context, - initialDate: _startDate, - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime.now().add(const Duration(days: 365 * 5)), - ); - if (picked != null) { - setState(() { - _startDate = picked; - // Ensure end date is after start date - if (_endDate.isBefore(_startDate)) { - _endDate = _startDate.add(const Duration(days: 30)); - } - }); - } - } - - Future _pickEndDate(BuildContext context) async { - final picked = await showAppDatePicker( - context: context, - initialDate: _endDate, - firstDate: _startDate, - lastDate: DateTime.now().add(const Duration(days: 365 * 5)), - ); - if (picked != null) { - setState(() => _endDate = picked); - } - } - - void _onCreate() { - final target = _selectedType == GoalType.minutes ? _durationMinutes : (int.tryParse(_targetController.text) ?? 0); - if (target <= 0) return; - - final GoalPeriod period; - final bool isRecurring; - DateTime? startDate; - DateTime? endDate; - - switch (_scheduleType) { - case GoalScheduleType.recurring: - period = _selectedPeriod; - isRecurring = true; - break; - case GoalScheduleType.oneOff: - period = _selectedPeriod; - isRecurring = false; - break; - case GoalScheduleType.custom: - period = GoalPeriod.custom; - isRecurring = false; - startDate = _startDate; - endDate = _endDate; - break; - } - - widget.onCreate?.call(_selectedType, target, period, isRecurring, startDate, endDate); - Navigator.of(context).pop(); - } - - String _getScheduleDescription() { - switch (_scheduleType) { - case GoalScheduleType.recurring: - return 'Goal resets and repeats each period (e.g., 30 min daily, every day)'; - case GoalScheduleType.oneOff: - return 'Single goal that ends when completed or period expires'; - case GoalScheduleType.custom: - return 'Set your own start and end dates for this goal'; - } - } - - String _getTypeLabel(GoalType type) { - switch (type) { - case GoalType.books: - return 'Books to read'; - case GoalType.pages: - return 'Pages to read'; - case GoalType.minutes: - return 'Reading time (minutes)'; + Future _save() async { + if (!_form.currentState!.validate()) return; + setState(() { + _saving = true; + _error = null; + }); + try { + if (widget.editing != null) { + await widget.provider.updateGoal( + goalId: widget.editing!.id, + target: int.parse(_target.text), + title: _title.text.trim(), + repository: _repository, + ); + } else { + await widget.provider.createGoal( + type: _type, + target: int.parse(_target.text), + period: _period, + isRecurring: _recurring, + title: _title.text.trim(), + scope: _scope, + scopeId: _scopeId, + minimumMinutes: int.parse(_threshold.text), + timezone: _zone.text.trim(), + endDate: _period == GoalPeriod.custom ? GoalCalendar.deadline(_deadline, _zone.text.trim()) : null, + repository: _repository, + ); + } + if (mounted) Navigator.pop(context); + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = error.toString(); + }); + } } } - - String _getTypeSuffix(GoalType type) { - switch (type) { - case GoalType.books: - return 'books'; - case GoalType.pages: - return 'pages'; - case GoalType.minutes: - return 'minutes'; - } - } - - String _getDefaultTarget(GoalType type) { - switch (type) { - case GoalType.books: - return '12'; - case GoalType.pages: - return '50'; - case GoalType.minutes: - return '30'; - } - } - - String _getPeriodLabel(GoalPeriod period) { - switch (period) { - case GoalPeriod.daily: - return 'Daily'; - case GoalPeriod.weekly: - return 'Weekly'; - case GoalPeriod.monthly: - return 'Monthly'; - case GoalPeriod.yearly: - return 'Yearly'; - case GoalPeriod.custom: - return 'Custom'; - } - } - - String _formatDate(DateTime date) { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; - } - - int _daysBetween() { - return _endDate.difference(_startDate).inDays + 1; - } } diff --git a/app/lib/widgets/goals/completed_goal_chip.dart b/app/lib/widgets/goals/completed_goal_chip.dart deleted file mode 100644 index e6208f63..00000000 --- a/app/lib/widgets/goals/completed_goal_chip.dart +++ /dev/null @@ -1,481 +0,0 @@ -import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; -import 'package:flutter/material.dart'; -import 'package:papyrus/models/reading_goal.dart'; -import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/themes/app_motion.dart'; - -/// Card displaying a completed goal with details. -class CompletedGoalChip extends StatelessWidget { - /// The completed goal to display. - final ReadingGoal goal; - - /// Called when the chip is tapped. - final VoidCallback? onTap; - - /// Called when the delete button is pressed. - final VoidCallback? onDelete; - - /// Whether to use expanded card layout (for desktop). - final bool isExpanded; - - const CompletedGoalChip({super.key, required this.goal, this.onTap, this.onDelete, this.isExpanded = false}); - - @override - Widget build(BuildContext context) { - if (isExpanded) return _buildExpandedCard(context); - return _buildCompactCard(context); - } - - // ============================================================================ - // COMPACT CARD (for horizontal scroll) - // ============================================================================ - - Widget _buildCompactCard(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return GestureDetector( - onTap: () => _showDetailsSheet(context), - child: Container( - width: 180, - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: colorScheme.outlineVariant, width: 1), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Header with checkmark and type badge - Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration(color: colorScheme.tertiary, shape: BoxShape.circle), - child: Icon(Icons.check, size: 14, color: colorScheme.onTertiary), - ), - const SizedBox(width: Spacing.xs), - Expanded( - child: Text( - _getGoalTypeLabel(), - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ), - Icon(Icons.chevron_right, size: 16, color: colorScheme.onSurfaceVariant), - ], - ), - const SizedBox(height: Spacing.sm), - // Target achieved - Text( - goal.type == GoalType.minutes ? formatDuration(goal.target) : '${goal.target} ${goal.typeLabel}', - style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: Spacing.xs), - // Completion info - Row( - children: [ - Icon(Icons.calendar_today, size: 12, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text( - _getCompletionDateLabel(), - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ], - ), - ), - ); - } - - // ============================================================================ - // EXPANDED CARD (for desktop wrap layout) - // ============================================================================ - - Widget _buildExpandedCard(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.lg), - child: InkWell( - onTap: () => _showDetailsSheet(context), - borderRadius: BorderRadius.circular(AppRadius.lg), - child: Container( - width: 220, - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: colorScheme.outlineVariant, width: 1), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Header - Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration(color: colorScheme.tertiary, shape: BoxShape.circle), - child: Icon(Icons.check, size: 16, color: colorScheme.onTertiary), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - goal.type == GoalType.minutes - ? formatDuration(goal.target) - : '${goal.target} ${goal.typeLabel}', - style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - Text( - _getGoalTypeLabel(), - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), - ), - ], - ), - ), - ], - ), - const SizedBox(height: Spacing.sm), - // Details row - Row( - children: [ - _buildInfoChip(context, icon: Icons.calendar_today, label: _getCompletionDateLabel()), - const SizedBox(width: Spacing.sm), - _buildInfoChip(context, icon: _getRecurrenceIcon(), label: goal.recurrenceLabel), - ], - ), - const SizedBox(height: Spacing.sm), - // View details hint - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text('Tap for details', style: textTheme.labelSmall?.copyWith(color: colorScheme.primary)), - const SizedBox(width: 4), - Icon(Icons.arrow_forward, size: 12, color: colorScheme.primary), - ], - ), - ], - ), - ), - ), - ); - } - - Widget _buildInfoChip(BuildContext context, {required IconData icon, required String label}) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.sm), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 10, color: colorScheme.onSurfaceVariant), - const SizedBox(width: 4), - Text(label, style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 10)), - ], - ), - ); - } - - // ============================================================================ - // DETAILS BOTTOM SHEET - // ============================================================================ - - /// Shows the completed goal details bottom sheet. - /// - /// Can be called externally to show details for a completed goal - /// without needing a [CompletedGoalChip] instance. - static void showDetailsSheet(BuildContext context, {required ReadingGoal goal, VoidCallback? onDelete}) { - showModalBottomSheet( - sheetAnimationStyle: AppMotion.animationStyle(context), - context: context, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), - isScrollControlled: true, - useSafeArea: true, - builder: (context) => _CompletedGoalDetailsSheet(goal: goal, onDelete: onDelete), - ); - } - - void _showDetailsSheet(BuildContext context) { - showDetailsSheet(context, goal: goal, onDelete: onDelete); - } - - // ============================================================================ - // HELPERS - // ============================================================================ - - String _getGoalTypeLabel() { - if (goal.isCustomPeriod) { - return 'Custom goal'; - } - if (goal.isRecurring) { - return 'Recurring ${_getPeriodLabel()}'; - } - return 'One-off ${_getPeriodLabel()}'; - } - - String _getPeriodLabel() { - switch (goal.period) { - case GoalPeriod.daily: - return 'daily'; - case GoalPeriod.weekly: - return 'weekly'; - case GoalPeriod.monthly: - return 'monthly'; - case GoalPeriod.yearly: - return 'yearly'; - case GoalPeriod.custom: - return ''; - } - } - - String _getCompletionDateLabel() { - if (goal.completedAt == null) { - return _formatShortDate(goal.endDate); - } - return _formatShortDate(goal.completedAt!); - } - - String _formatShortDate(DateTime date) { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${months[date.month - 1]} ${date.year}'; - } - - IconData _getRecurrenceIcon() { - if (goal.isCustomPeriod) return Icons.date_range; - if (goal.isRecurring) return Icons.repeat; - return Icons.looks_one; - } -} - -// ============================================================================ -// DETAILS SHEET WIDGET -// ============================================================================ - -class _CompletedGoalDetailsSheet extends StatelessWidget { - final ReadingGoal goal; - final VoidCallback? onDelete; - - const _CompletedGoalDetailsSheet({required this.goal, this.onDelete}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - return AppBottomSheet( - title: 'Goal completed', - onClose: () => Navigator.of(context).pop(), - footer: BottomSheetActions( - secondary: OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - _showDeleteConfirmation(context); - }, - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide(color: colorScheme.error), - ), - child: const Text('Delete goal'), - ), - primary: FilledButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Done')), - ), - body: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(goal.description, style: textTheme.titleMedium), - const SizedBox(height: Spacing.lg), - // Stats grid - Container( - padding: const EdgeInsets.all(Spacing.md), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - children: [ - _buildDetailRow( - context, - icon: Icons.track_changes, - label: 'Target achieved', - value: goal.type == GoalType.minutes - ? '${formatDuration(goal.current)} of ${formatDuration(goal.target)}' - : '${goal.current} of ${goal.target} ${goal.typeLabel}', - ), - const Divider(height: Spacing.lg), - _buildDetailRow( - context, - icon: Icons.percent, - label: 'Progress', - value: '${(goal.progress * 100).round()}%', - valueColor: colorScheme.tertiary, - ), - const Divider(height: Spacing.lg), - _buildDetailRow( - context, - icon: _getRecurrenceIcon(), - label: 'Goal type', - value: _getFullGoalTypeLabel(), - ), - const Divider(height: Spacing.lg), - _buildDetailRow(context, icon: Icons.date_range, label: 'Period', value: _getPeriodDescription()), - const Divider(height: Spacing.lg), - _buildDetailRow(context, icon: Icons.check_circle, label: 'Completed on', value: _getCompletionDate()), - ], - ), - ), - const SizedBox(height: Spacing.lg), - ], - ), - ); - } - - Widget _buildDetailRow( - BuildContext context, { - required IconData icon, - required String label, - required String value, - Color? valueColor, - }) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Row( - children: [ - Icon(icon, size: 20, color: colorScheme.onSurfaceVariant), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text(label, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - value, - textAlign: TextAlign.end, - style: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: valueColor), - ), - ), - ], - ); - } - - void _showDeleteConfirmation(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - showDialog( - animationStyle: AppMotion.animationStyle(context), - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete goal?'), - content: Text('This will permanently remove "${goal.description}" from your completed goals.'), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.of(context).pop(); - onDelete?.call(); - }, - style: FilledButton.styleFrom(backgroundColor: colorScheme.error), - child: const Text('Delete'), - ), - ], - ), - ); - } - - String _getFullGoalTypeLabel() { - if (goal.isCustomPeriod) { - return 'Custom date range'; - } - final period = _getPeriodLabel(); - if (goal.isRecurring) { - return 'Recurring $period goal'; - } - return 'One-off $period goal'; - } - - String _getPeriodLabel() { - switch (goal.period) { - case GoalPeriod.daily: - return 'daily'; - case GoalPeriod.weekly: - return 'weekly'; - case GoalPeriod.monthly: - return 'monthly'; - case GoalPeriod.yearly: - return 'yearly'; - case GoalPeriod.custom: - return 'custom'; - } - } - - String _getPeriodDescription() { - if (goal.isCustomPeriod) { - return '${_formatDate(goal.startDate)} - ${_formatDate(goal.endDate)}'; - } - switch (goal.period) { - case GoalPeriod.daily: - return _formatDate(goal.startDate); - case GoalPeriod.weekly: - return 'Week of ${_formatDate(goal.startDate)}'; - case GoalPeriod.monthly: - return _formatMonthYear(goal.startDate); - case GoalPeriod.yearly: - return '${goal.startDate.year}'; - case GoalPeriod.custom: - return '${_formatDate(goal.startDate)} - ${_formatDate(goal.endDate)}'; - } - } - - String _getCompletionDate() { - if (goal.completedAt == null) return 'Unknown'; - return _formatDate(goal.completedAt!); - } - - String _formatDate(DateTime date) { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; - } - - String _formatMonthYear(DateTime date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - return '${months[date.month - 1]} ${date.year}'; - } - - IconData _getRecurrenceIcon() { - if (goal.isCustomPeriod) return Icons.date_range; - if (goal.isRecurring) return Icons.repeat; - return Icons.looks_one; - } -} diff --git a/app/lib/widgets/goals/goal_card.dart b/app/lib/widgets/goals/goal_card.dart index f24c9319..dfaff635 100644 --- a/app/lib/widgets/goals/goal_card.dart +++ b/app/lib/widgets/goals/goal_card.dart @@ -1,51 +1,152 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/goal_progress.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; -/// Unified goal card widget displaying consistent linear progress. -/// -/// All goals (daily, weekly, monthly, yearly) use the same layout -/// with linear progress bars for visual consistency and scanability. +IconData goalIcon(GoalType type) => switch (type) { + GoalType.books => Icons.book_outlined, + GoalType.pages => Icons.menu_book_outlined, + GoalType.minutes => Icons.schedule_outlined, + GoalType.days => Icons.calendar_today_outlined, +}; +String goalCount(GoalProgress progress) => + '${progress.value} / ${progress.goal.targetValue} ${progress.goal.typeLabel}'; + class GoalCard extends StatelessWidget { - /// The reading goal to display. + const GoalCard({ + super.key, + required this.goal, + this.progress, + this.onTap, + this.onContinue, + this.onMenu, + this.scopeLabel, + this.isDesktop = false, + }); final ReadingGoal goal; - - /// Called when the card is tapped. + final GoalProgress? progress; final VoidCallback? onTap; - - /// Whether to use desktop styling. + final VoidCallback? onContinue; + final void Function(String)? onMenu; + final String? scopeLabel; final bool isDesktop; - - const GoalCard({super.key, required this.goal, this.onTap, this.isDesktop = false}); - @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final textTheme = Theme.of(context).textTheme; - - return Material( - color: colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(AppRadius.xl), + final colors = Theme.of(context).colorScheme; + final texts = Theme.of(context).textTheme; + final projected = progress?.projected ?? goal; + final today = GoalCalendar.midnight(DateTime.now(), goal.timezone); + final expired = progress?.range.end.isAfter(DateTime.now().toUtc()) == false; + final state = projected.isArchived + ? 'Archived' + : !projected.isActive + ? 'Paused' + : expired + ? projected.isCompleted + ? 'Achieved' + : 'Missed' + : projected.isCompleted + ? 'Target reached' + : null; + return Card( + margin: EdgeInsets.zero, + elevation: 0, + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xl), + side: BorderSide(color: colors.outlineVariant), + ), child: InkWell( - onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.xl), - child: Container( - padding: EdgeInsets.all(isDesktop ? Spacing.lg : Spacing.md), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.xl), - border: Border.all(color: colorScheme.outlineVariant, width: 1), - ), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(Spacing.lg), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - _buildHeader(context, colorScheme, textTheme), - const SizedBox(height: Spacing.md), - _buildProgressValues(context, colorScheme, textTheme), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(goalIcon(goal.type), color: colors.primary), + const SizedBox(width: Spacing.sm), + Expanded(child: Text(projected.displayTitle, style: texts.titleMedium)), + if (onMenu != null) + PopupMenuButton( + tooltip: 'Goal actions', + onSelected: onMenu, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'edit', child: Text('Edit target')), + PopupMenuItem(value: 'pause', child: Text(goal.isActive ? 'Pause' : 'Resume')), + const PopupMenuItem(value: 'archive', child: Text('Archive')), + const PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + ], + ), + const SizedBox(height: Spacing.lg), + Text( + '${projected.currentValue} / ${projected.targetValue} ${projected.typeLabel}', + style: texts.titleLarge, + ), const SizedBox(height: Spacing.sm), - _buildProgressBar(colorScheme), - const SizedBox(height: Spacing.sm), - _buildFooter(colorScheme, textTheme), + Semantics( + label: + '${projected.displayTitle}, ${projected.currentValue} of ${projected.targetValue} ${projected.typeLabel}', + child: LinearProgressIndicator( + value: progress?.fraction ?? projected.progress, + minHeight: 6, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + const SizedBox(height: Spacing.md), + if (state != null) + Row( + children: [ + Icon( + projected.isCompleted + ? Icons.check_circle_outline + : goal.isActive + ? Icons.event_outlined + : Icons.pause_circle_outline, + size: 18, + ), + const SizedBox(width: Spacing.sm), + Expanded(child: Text(state, style: texts.bodyMedium)), + ], + ) + else + Text(projected.statusText, style: texts.bodyMedium), + const SizedBox(height: Spacing.xs), + Text( + '${scopeLabel ?? 'Whole library'} · ${goal.isRecurring ? projected.periodLabel : 'Until ${DateFormat.yMMMd().format(GoalCalendar.local(projected.endDate.subtract(const Duration(microseconds: 1)), goal.timezone))}'}${progress?.estimated == true && goal.type == GoalType.pages ? ' · Estimated pages included' : ''}', + style: texts.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + if (goal.type == GoalType.days) ...[ + const SizedBox(height: Spacing.md), + if (goal.period == GoalPeriod.weekly && progress != null) + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [for (var i = 0; i < 7; i++) _day(context, i, today)], + ), + const SizedBox(height: Spacing.sm), + Text('${goal.minimumMinutes} minutes qualifies a day', style: texts.bodySmall), + ], + if (onContinue != null && !expired && !goal.isArchived && goal.isActive) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: onContinue, + icon: const Icon(Icons.play_arrow_outlined), + label: const Text('Continue reading'), + ), + ), + ), ], ), ), @@ -53,214 +154,30 @@ class GoalCard extends StatelessWidget { ); } - // ============================================================================ - // CARD SECTIONS - // ============================================================================ - - /// Builds the header with icon, title, and optional badges. - Widget _buildHeader(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - return Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Icon(_getIconForType(goal.type), size: 20, color: colorScheme.onPrimaryContainer), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: Text( - goal.description, - style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (goal.isDaily && goal.isRecurring && goal.streak > 0) ...[ - const SizedBox(width: Spacing.sm), - _buildStreakBadge(colorScheme, textTheme), - ], - if (!goal.isRecurring && !goal.isCustomPeriod) ...[ - const SizedBox(width: Spacing.sm), - _buildOneOffBadge(colorScheme, textTheme), - ], - if (goal.isCustomPeriod) ...[const SizedBox(width: Spacing.sm), _buildDateRangeBadge(colorScheme, textTheme)], - if (goal.isCompleted) ...[ - const SizedBox(width: Spacing.sm), - Icon(Icons.check_circle, size: 24, color: colorScheme.tertiary), - ], - ], - ); - } - - /// Builds the streak badge for recurring daily goals. - Widget _buildStreakBadge(ColorScheme colorScheme, TextTheme textTheme) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.local_fire_department, size: 14, color: colorScheme.onTertiaryContainer), - const SizedBox(width: 4), - Text( - '${goal.streak}', - style: textTheme.labelSmall?.copyWith(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.bold), - ), - ], - ), - ); - } - - /// Builds the date range badge for custom period goals. - Widget _buildDateRangeBadge(ColorScheme colorScheme, TextTheme textTheme) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.date_range, size: 14, color: colorScheme.onSecondaryContainer), - const SizedBox(width: 4), - Text( - _formatDateRange(), - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w500), - ), - ], - ), - ); - } - - String _formatDateRange() { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - final start = '${months[goal.startDate.month - 1]} ${goal.startDate.day}'; - final end = '${months[goal.endDate.month - 1]} ${goal.endDate.day}'; - return '$start - $end'; - } - - /// Builds the "One-off" badge for non-recurring goals. - Widget _buildOneOffBadge(ColorScheme colorScheme, TextTheme textTheme) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(AppRadius.full), - ), - child: Text( - 'One-off', - style: textTheme.labelSmall?.copyWith(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w500), - ), - ); - } - - /// Builds the progress values row showing current/target and percentage. - Widget _buildProgressValues(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - goal.type == GoalType.minutes - ? '${formatDuration(goal.current)} of ${formatDuration(goal.target)}' - : '${goal.current} of ${goal.target} ${goal.typeLabel}', - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + Widget _day(BuildContext context, int index, DateTime today) { + final start = GoalCalendar.local(progress!.range.start, goal.timezone); + final localDate = GoalCalendar.dayOffset(start, index, goal.timezone); + final done = progress!.qualifiedDays.contains(localDate); + final colors = Theme.of(context).colorScheme; + return Tooltip( + message: + '${DateFormat.yMMMd().format(GoalCalendar.local(localDate, goal.timezone))}: ${done ? 'qualified' : 'not qualified'}', + child: Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: done ? colors.secondaryContainer : colors.surface, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: localDate == today ? colors.primary : colors.outlineVariant), ), - Text( - goal.progressLabel, - style: textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: (goal.isCompleted && goal.isArchived) ? colorScheme.tertiary : colorScheme.primary, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(['M', 'T', 'W', 'T', 'F', 'S', 'S'][index]), + const SizedBox(height: Spacing.xs), + Icon(done ? Icons.check : Icons.remove, size: 16), + ], ), - ], - ); - } - - /// Builds the linear progress bar. - Widget _buildProgressBar(ColorScheme colorScheme) { - return ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.sm), - child: AppLinearProgressIndicator( - value: goal.progress.clamp(0.0, 1.0), - minHeight: 8, - backgroundColor: colorScheme.surfaceContainerHighest, - color: (goal.isCompleted && goal.isArchived) ? colorScheme.tertiary : colorScheme.primary, ), ); } - - /// Builds the footer with status text and time context. - Widget _buildFooter(ColorScheme colorScheme, TextTheme textTheme) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - goal.isCompleted - ? (goal.isRecurring && !goal.isArchived ? "Today's goal met" : 'Completed!') - : goal.type == GoalType.minutes - ? '${formatDuration(goal.remaining)} to go' - : '${goal.remaining} ${goal.typeLabel} to go', - style: textTheme.bodySmall?.copyWith( - color: goal.isCompleted - ? (goal.isRecurring && !goal.isArchived ? colorScheme.primary : colorScheme.tertiary) - : colorScheme.onSurfaceVariant, - fontWeight: goal.isCompleted ? FontWeight.w500 : FontWeight.normal, - ), - ), - Text(_getTimeContext(), style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ); - } - - // ============================================================================ - // HELPERS - // ============================================================================ - - String _getTimeContext() { - final daysLeft = goal.endDate.difference(DateTime.now()).inDays; - - switch (goal.period) { - case GoalPeriod.daily: - return 'Today'; - case GoalPeriod.weekly: - return daysLeft <= 0 ? 'This week' : '$daysLeft days left'; - case GoalPeriod.monthly: - return daysLeft <= 7 ? '$daysLeft days left' : 'This month'; - case GoalPeriod.yearly: - return 'Ends ${_formatDate(goal.endDate)}'; - case GoalPeriod.custom: - if (daysLeft <= 0) return 'Ends today'; - if (daysLeft <= 7) return '$daysLeft days left'; - return '${_formatShortDate(goal.startDate)} - ${_formatShortDate(goal.endDate)}'; - } - } - - String _formatDate(DateTime date) { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${months[date.month - 1]} ${date.day}, ${date.year}'; - } - - String _formatShortDate(DateTime date) { - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return '${months[date.month - 1]} ${date.day}'; - } - - IconData _getIconForType(GoalType type) { - switch (type) { - case GoalType.books: - return Icons.menu_book_outlined; - case GoalType.pages: - return Icons.article_outlined; - case GoalType.minutes: - return Icons.schedule_outlined; - } - } } diff --git a/app/lib/widgets/goals/goal_details_sheet.dart b/app/lib/widgets/goals/goal_details_sheet.dart new file mode 100644 index 00000000..93b37a80 --- /dev/null +++ b/app/lib/widgets/goals/goal_details_sheet.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; +import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; +import 'package:papyrus/widgets/goals/goal_card.dart'; +import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; + +class GoalDetailsSheet extends StatefulWidget { + const GoalDetailsSheet({super.key, required this.goal, required this.provider, this.historical}); + final ReadingGoal goal; + final GoalsProvider provider; + final GoalProgress? historical; + static Future show( + BuildContext context, { + required ReadingGoal goal, + required GoalsProvider provider, + GoalProgress? historical, + }) => showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + constraints: const BoxConstraints(maxWidth: 640), + sheetAnimationStyle: AppMotion.animationStyle(context), + builder: (_) => GoalDetailsSheet(goal: goal, provider: provider, historical: historical), + ); + @override + State createState() => _GoalDetailsSheetState(); +} + +class _GoalDetailsSheetState extends State { + late final TrackingRepository? _repository = widget.provider.store.trackingRepository; + bool _saving = false; + String? _error; + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: widget.provider, + builder: (context, _) { + final goal = widget.provider.store.getReadingGoal(widget.goal.id) ?? widget.goal; + final history = widget.historical; + final progress = history == null + ? widget.provider.progress(goal) + : projectGoal( + history.goal, + widget.provider.store.readingActivities, + widget.provider.now, + period: history.range, + ); + final periods = widget.provider.history.where((period) => period.goal.id == goal.id).toList(); + return AppBottomSheet( + title: 'Goal details', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GoalCard(goal: goal, progress: progress), + const SizedBox(height: Spacing.lg), + Text('Calendar timezone: ${goal.timezone}', style: Theme.of(context).textTheme.bodySmall), + if (widget.historical == null && !goal.isArchived) ...[ + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + onPressed: _saving + ? null + : () => AddGoalSheet.show(context, provider: widget.provider, editing: goal), + icon: const Icon(Icons.edit_outlined), + label: const Text('Edit target'), + ), + OutlinedButton.icon( + onPressed: _saving + ? null + : () => + _action(() => widget.provider.pauseGoal(goal.id, goal.isActive, repository: _repository)), + icon: Icon(goal.isActive ? Icons.pause : Icons.play_arrow), + label: Text(goal.isActive ? 'Pause goal' : 'Resume goal'), + ), + OutlinedButton.icon( + onPressed: _saving + ? null + : () => _action(() => widget.provider.archiveGoal(goal.id, repository: _repository)), + icon: const Icon(Icons.archive_outlined), + label: const Text('Archive goal'), + ), + ], + ), + ], + if (goal.isArchived && widget.provider.store.getReadingGoal(goal.id) != null) + TextButton.icon( + onPressed: _saving + ? null + : () => _action(() => widget.provider.restoreGoal(goal.id, repository: _repository)), + icon: const Icon(Icons.unarchive_outlined), + label: const Text('Restore goal'), + ), + const SizedBox(height: Spacing.lg), + Text('Counted activity', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (progress.activities.isEmpty) + const Text('No qualifying reading in this period yet.') + else + ...groupReadingActivities( + progress.activities, + ).map((activity) => ReadingActivityTile(activity: activity, provider: widget.provider)), + if (periods.isNotEmpty && widget.historical == null) ...[ + const SizedBox(height: Spacing.lg), + Text('Previous periods', style: Theme.of(context).textTheme.titleMedium), + for (final period in periods) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(period.reached ? Icons.check_circle_outline : Icons.event_outlined), + title: Text( + '${DateFormat.yMMMd().format(GoalCalendar.local(period.range.start, goal.timezone))} – ${DateFormat.yMMMd().format(GoalCalendar.local(period.range.end.subtract(const Duration(microseconds: 1)), goal.timezone))}', + ), + subtitle: Text('${goalCount(period)} · ${period.reached ? 'Achieved' : 'Missed'}'), + onTap: () => + GoalDetailsSheet.show(context, goal: period.goal, provider: widget.provider, historical: period), + ), + ], + if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ), + footer: BottomSheetFormActions( + onCancel: _saving || widget.provider.store.getReadingGoal(goal.id) == null ? null : _delete, + cancelLabel: 'Delete goal', + onSave: _saving ? null : () => Navigator.pop(context), + saveLabel: 'Done', + ), + ); + }, + ); + Future _action(Future Function() action) async { + setState(() { + _saving = true; + _error = null; + }); + try { + await action(); + } catch (error) { + _error = error.toString(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + final confirmed = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (context) => AlertDialog( + title: const Text('Delete goal?'), + content: const Text('Reading activity and previous periods remain in your history.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete goal')), + ], + ), + ); + if (confirmed != true || !mounted) return; + await _action(() => widget.provider.deleteGoal(widget.goal.id, repository: _repository)); + if (mounted && _error == null) Navigator.pop(context); + } +} diff --git a/app/lib/widgets/goals/log_reading_sheet.dart b/app/lib/widgets/goals/log_reading_sheet.dart new file mode 100644 index 00000000..d3844980 --- /dev/null +++ b/app/lib/widgets/goals/log_reading_sheet.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; +import 'package:papyrus/widgets/shared/app_date_picker.dart'; + +class LogReadingSheet extends StatefulWidget { + const LogReadingSheet({super.key, required this.provider, this.book, this.correcting}); + final GoalsProvider provider; + final Book? book; + final ReadingActivity? correcting; + static Future show( + BuildContext context, { + required GoalsProvider provider, + Book? book, + ReadingActivity? correcting, + }) => showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + sheetAnimationStyle: AppMotion.animationStyle(context), + constraints: const BoxConstraints(maxWidth: 640), + builder: (_) => LogReadingSheet(provider: provider, book: book, correcting: correcting), + ); + @override + State createState() => _LogReadingSheetState(); +} + +class _LogReadingSheetState extends State { + final _form = GlobalKey(); + final _minutes = TextEditingController(); + final _pages = TextEditingController(); + final _note = TextEditingController(); + late final TrackingRepository? _repository; + String? _bookId; + DateTime _end = DateTime.now(); + bool _finished = false; + bool _saving = false; + String? _error; + @override + void initState() { + super.initState(); + _repository = widget.provider.store.trackingRepository; + _bookId = widget.book?.id ?? widget.correcting?.bookId; + final original = widget.correcting; + _minutes.text = original == null ? '' : '${original.seconds ~/ 60}'; + _pages.text = original == null ? '' : '${original.pages}'; + _note.text = original?.note ?? ''; + if (original != null) { + _end = original.endTime.toLocal(); + _finished = original.kind == 'completion'; + } + } + + @override + void dispose() { + _minutes.dispose(); + _pages.dispose(); + _note.dispose(); + super.dispose(); + } + + String? _nonNegative(String? value) { + if (value == null || value.isEmpty) return null; + final n = int.tryParse(value); + return n == null || n < 0 || n > 100000 ? 'Enter a number from 0 to 100,000.' : null; + } + + @override + Widget build(BuildContext context) { + final books = widget.provider.store.books; + return AppBottomSheet( + title: widget.correcting == null ? 'Log reading' : 'Correct reading entry', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: books.any((book) => book.id == _bookId) ? _bookId : null, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Book'), + items: [ + for (final book in books) + DropdownMenuItem( + value: book.id, + child: Text(book.title, overflow: TextOverflow.ellipsis), + ), + ], + validator: (value) => value == null ? 'Choose a book.' : null, + onChanged: _saving || widget.correcting != null ? null : (value) => setState(() => _bookId = value), + ), + if (books.isEmpty) + const Padding( + padding: EdgeInsets.only(top: Spacing.sm), + child: Text('Add a digital or physical book to your library before logging reading.'), + ), + const SizedBox(height: Spacing.lg), + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final date = await showAppDatePicker( + context: context, + initialDate: _end, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (date != null && mounted) { + setState(() => _end = DateTime(date.year, date.month, date.day, _end.hour, _end.minute)); + } + }, + icon: const Icon(Icons.event_outlined), + label: Text(DateFormat.yMMMd().format(_end)), + ), + OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final time = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (_) => TimePickerDialog(initialTime: TimeOfDay.fromDateTime(_end)), + ); + if (time != null && mounted) { + setState(() => _end = DateTime(_end.year, _end.month, _end.day, time.hour, time.minute)); + } + }, + icon: const Icon(Icons.schedule), + label: Text(DateFormat.Hm().format(_end)), + ), + ], + ), + const SizedBox(height: Spacing.lg), + TextFormField( + controller: _minutes, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Minutes read', + helperText: 'The session ends at the time above.', + ), + validator: _nonNegative, + ), + const SizedBox(height: Spacing.md), + TextFormField( + controller: _pages, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Pages read'), + validator: _nonNegative, + ), + const SizedBox(height: Spacing.md), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('I finished this book'), + value: _finished, + onChanged: _saving ? null : (value) => setState(() => _finished = value!), + ), + const SizedBox(height: Spacing.md), + TextFormField( + controller: _note, + maxLength: 10000, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Note (optional)'), + ), + const SizedBox(height: Spacing.md), + const Text( + 'Manual entries count toward matching goals. Reading before a goal was created stays in your history but does not count toward that goal.', + ), + if (widget.correcting != null) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('The original entry is retained as corrected in your activity history.'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ), + ), + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context), + onSave: _saving || books.isEmpty ? null : _save, + saveLabel: _saving ? 'Saving…' : 'Save reading', + ), + ); + } + + Future _save() async { + if (!_form.currentState!.validate()) return; + final book = widget.provider.store.getBook(_bookId!); + if (book == null) { + setState(() => _error = 'This book no longer exists.'); + return; + } + setState(() { + _saving = true; + _error = null; + }); + try { + await widget.provider.logReading( + book: book, + end: _end.toUtc(), + minutes: int.tryParse(_minutes.text) ?? 0, + pages: int.tryParse(_pages.text) ?? 0, + finished: _finished, + note: _note.text.trim().isEmpty ? null : _note.text.trim(), + correcting: widget.correcting, + repository: _repository, + ); + if (mounted) Navigator.pop(context); + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = error.toString(); + }); + } + } + } +} + +class ReadingActivityTile extends StatelessWidget { + const ReadingActivityTile({super.key, required this.activity, required this.provider, this.corrected = false}); + final ReadingActivity activity; + final GoalsProvider provider; + final bool corrected; + @override + Widget build(BuildContext context) { + final label = activity.kind == 'completion' + ? 'Finished book' + : activity.kind == 'reversal' + ? 'Correction' + : [ + if (activity.seconds > 0) + activity.seconds < 60 ? '${activity.seconds} sec' : '${activity.seconds ~/ 60} min', + if (activity.pages > 0) '${activity.pages} pages', + if (activity.coverage.isNotEmpty) activity.isEstimated ? 'Estimated pages' : 'PDF pages', + ].join(' · '); + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + activity.kind == 'completion' + ? Icons.check_circle_outline + : activity.source == 'reader' + ? Icons.auto_stories_outlined + : Icons.edit_outlined, + ), + title: Text(activity.bookTitle), + subtitle: Text( + '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}\n${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', + ), + isThreeLine: true, + onTap: () => _details(context), + ); + } + + Future _details(BuildContext context) => showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + constraints: const BoxConstraints(maxWidth: 640), + sheetAnimationStyle: AppMotion.animationStyle(context), + builder: (sheetContext) => _ActivityDetails(provider: provider, activity: activity, corrected: corrected), + ); +} + +class _ActivityDetails extends StatefulWidget { + const _ActivityDetails({required this.provider, required this.activity, required this.corrected}); + final GoalsProvider provider; + final ReadingActivity activity; + final bool corrected; + @override + State<_ActivityDetails> createState() => _ActivityDetailsState(); +} + +class _ActivityDetailsState extends State<_ActivityDetails> { + late final TrackingRepository? _repository = widget.provider.store.trackingRepository; + bool _saving = false; + String? _error; + @override + Widget build(BuildContext context) { + final activity = widget.activity; + final book = widget.provider.store.getBook(activity.bookId); + return AppBottomSheet( + title: 'Reading activity', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(activity.bookTitle, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.md), + Text( + '${activity.source == 'reader' ? 'Recorded by the reader' : 'Logged manually'} · ${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', + ), + const SizedBox(height: Spacing.md), + if (activity.kind == 'reading') + Text( + '${activity.seconds ~/ 60} minutes · ${activity.pages > 0 + ? '${activity.pages} manual pages' + : activity.isEstimated + ? 'Estimated EPUB coverage' + : activity.coverage.isEmpty + ? 'No pages logged' + : 'PDF coverage'}', + ), + if (activity.kind == 'completion') const Text('Explicitly marked as finished.'), + if (activity.note != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(activity.note!), + ), + if (widget.corrected) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('This entry was corrected and no longer contributes.'), + ), + if (book == null) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('The book was removed. Its activity history is retained.'), + ), + if (!widget.corrected && activity.source == 'manual' && book != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: OutlinedButton.icon( + onPressed: _saving + ? null + : () { + final parent = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + LogReadingSheet.show(parent, provider: widget.provider, book: book, correcting: activity); + }, + icon: const Icon(Icons.edit_outlined), + label: const Text('Correct entry'), + ), + ), + if (!widget.corrected) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: OutlinedButton.icon( + onPressed: _saving ? null : _undo, + icon: const Icon(Icons.undo), + label: const Text('Undo this entry'), + ), + ), + if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ), + footer: BottomSheetFormActions( + onCancel: null, + onSave: _saving ? null : () => Navigator.pop(context), + saveLabel: 'Done', + ), + ); + } + + Future _undo() async { + final confirmed = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (context) => AlertDialog( + title: const Text('Undo reading entry?'), + content: const Text( + 'This removes its contribution from goals and statistics. The original entry stays in your history.', + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Undo entry')), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _saving = true); + try { + await widget.provider.reverseActivity(widget.activity, repository: _repository); + if (mounted) Navigator.pop(context); + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = error.toString(); + }); + } + } + } +} diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc index 34f7b9ad..3bd008cd 100644 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ b/app/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_timezone_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterTimezonePlugin"); + flutter_timezone_plugin_register_with_registrar(flutter_timezone_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake index 6000de5f..c8827c24 100644 --- a/app/linux/flutter/generated_plugins.cmake +++ b/app/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop desktop_webview_window flutter_secure_storage_linux + flutter_timezone url_launcher_linux window_to_front ) diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index cb45b9ef..65cf75b9 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import desktop_drop import desktop_webview_window import file_picker import flutter_secure_storage_darwin +import flutter_timezone import flutter_web_auth_2 import mobile_scanner import pdfium_flutter @@ -22,6 +23,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) PDFiumFlutterPlugin.register(with: registry.registrar(forPlugin: "PDFiumFlutterPlugin")) diff --git a/app/pubspec.lock b/app/pubspec.lock index 313c97e4..35162962 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -181,10 +181,10 @@ packages: dependency: transitive description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" fake_async: dependency: transitive description: @@ -336,6 +336,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_timezone: + dependency: "direct main" + description: + name: flutter_timezone + sha256: "7830bc64bef0b003468fc1f58fc3f3faf54817f8cd6e68fa818b306560410d68" + url: "https://pub.dev" + source: hosted + version: "5.1.1" flutter_web_auth_2: dependency: "direct main" description: @@ -555,8 +563,8 @@ packages: dependency: "direct main" description: path: "." - ref: "7992c8b518fcb900f941217a7ea8455d001c1aea" - resolved-ref: "7992c8b518fcb900f941217a7ea8455d001c1aea" + ref: bc617deeaf2ae684f20c1000ed7cd52f9a61d0a7 + resolved-ref: bc617deeaf2ae684f20c1000ed7cd52f9a61d0a7 url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" @@ -785,7 +793,7 @@ packages: source: hosted version: "2.4.1" shared_preferences_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: shared_preferences_platform_interface sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" @@ -981,6 +989,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.9" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" typed_data: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 5d529aaa..2d40d615 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: 7992c8b518fcb900f941217a7ea8455d001c1aea + ref: bc617deeaf2ae684f20c1000ed7cd52f9a61d0a7 epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^32.2.3 dart_mobi: ^1.0.2 @@ -46,6 +46,8 @@ dependencies: shared_preferences: ^2.5.4 flutter_secure_storage: ^10.1.0 uuid: ^4.5.1 + timezone: ^0.11.1 + flutter_timezone: ^5.1.1 dev_dependencies: flutter_test: @@ -57,6 +59,7 @@ dev_dependencies: path_provider_platform_interface: ^2.1.2 plugin_platform_interface: ^2.1.8 + shared_preferences_platform_interface: ^2.4.1 flutter: config: enable-swift-package-manager: false diff --git a/app/test/goals/fixtures/projections.json b/app/test/goals/fixtures/projections.json new file mode 100644 index 00000000..36d8a8d3 --- /dev/null +++ b/app/test/goals/fixtures/projections.json @@ -0,0 +1,562 @@ +[ + { + "name": "creation cutoff and overlapping devices", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 1200, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 20 + } + }, + { + "name": "pause excludes intervals without extending period", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [ + { + "at": "2026-10-05T10:00:00Z", + "target": 30, + "title": null, + "active": true, + "archived": false + }, + { + "at": "2026-10-05T10:05:00Z", + "target": 30, + "title": null, + "active": false, + "archived": false + }, + { + "at": "2026-10-05T10:15:00Z", + "target": 30, + "title": null, + "active": true, + "archived": false + } + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 10 + } + }, + { + "name": "coverage union uses stable calibration", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000d", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:10:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0.25, + "end": 0.75, + "pages_per_unit": 40, + "estimated": true + } + ] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:00:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0, + "end": 0.5, + "pages_per_unit": 20, + "estimated": true + } + ] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:00:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "epub:0:500", + "start": 0, + "end": 0.5, + "pages_per_unit": 20, + "estimated": true + } + ] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 1200, + "pages": 15, + "finished_books": 0, + "days": 1, + "current_value": 15 + } + }, + { + "name": "distinct books and completion undo", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000e", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:15:00Z", + "end_time": "2026-10-05T10:15:00Z", + "created_at": "2026-10-05T10:15:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000f", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:18:00Z", + "end_time": "2026-10-05T10:18:00Z", + "created_at": "2026-10-05T10:18:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000010", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000e" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 1, + "days": 0, + "current_value": 1 + } + }, + { + "name": "undo last completion", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000e", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:15:00Z", + "end_time": "2026-10-05T10:15:00Z", + "created_at": "2026-10-05T10:15:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000f", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:18:00Z", + "end_time": "2026-10-05T10:18:00Z", + "created_at": "2026-10-05T10:18:00Z", + "source": "manual", + "kind": "completion", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000010", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000e" + }, + { + "id": "00000000-0000-0000-0000-000000000011", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T11:00:00Z", + "end_time": "2026-10-05T11:00:00Z", + "created_at": "2026-10-05T11:00:00Z", + "source": "manual", + "kind": "reversal", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-00000000000f" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 0, + "days": 0, + "current_value": 0 + } + }, + { + "name": "midnight checkpoint credits exposed prior-day PDF page", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000012", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T23:59:50Z", + "end_time": "2026-10-06T00:00:00Z", + "created_at": "2026-10-06T00:00:00Z", + "source": "reader", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [ + { + "key": "pdf:9", + "start": 0, + "end": 1, + "pages_per_unit": 1, + "estimated": false + } + ] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 10, + "pages": 1, + "finished_books": 0, + "days": 0, + "current_value": 1 + } + }, + { + "name": "spring DST day has twenty-three hours", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "daily", + "start_date": "2026-03-28T22:00:00Z", + "end_date": "2026-03-29T21:00:00Z", + "created_at": "2026-03-28T22:00:00Z", + "timezone": "Europe/Vilnius", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": true, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000013", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-03-28T22:00:00Z", + "end_time": "2026-03-29T21:00:00Z", + "created_at": "2026-03-29T21:00:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-03-29T20:59:59.999999Z", + "expected": { + "seconds": 82799, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 1379 + } + }, + { + "name": "captured shelf scope and reading-day threshold", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_days", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "shelf", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000003" + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T09:50:00Z", + "end_time": "2026-10-05T10:10:00Z", + "created_at": "2026-10-05T10:10:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [ + "00000000-0000-0000-0000-000000000003" + ], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:20:00Z", + "created_at": "2026-10-05T10:20:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [ + "00000000-0000-0000-0000-000000000004" + ], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 0, + "finished_books": 0, + "days": 1, + "current_value": 1 + } + } +] diff --git a/app/test/goals/goal_progress_test.dart b/app/test/goals/goal_progress_test.dart new file mode 100644 index 00000000..25714ae7 --- /dev/null +++ b/app/test/goals/goal_progress_test.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; + +void main() { + final fixtures = jsonDecode(File('test/goals/fixtures/projections.json').readAsStringSync()) as List; + for (final fixture in fixtures.cast>()) { + test(fixture['name'] as String, () { + final goal = ReadingGoal.fromJson(fixture['goal'] as Map); + final ledger = (fixture['activities'] as List).map((a) => ReadingActivity.fromJson(a as Map)); + final now = DateTime.parse(fixture['now'] as String); + final totals = projectGoal(goal, ledger, now); + final expected = fixture['expected'] as Map; + expect(totals.seconds, expected['seconds']); + expect(totals.pages, closeTo((expected['pages'] as num).toDouble(), .0001)); + expect(totals.finishedBooks, expected['finished_books']); + expect(totals.days, expected['days']); + expect(totals.value, expected['current_value']); + }); + } + test('Monday weeks and both DST boundaries use calendar days', () { + final spring = GoalCalendar.calendarPeriod(GoalPeriod.daily, DateTime.utc(2026, 3, 29, 12), 'Europe/Vilnius'); + final autumn = GoalCalendar.calendarPeriod(GoalPeriod.daily, DateTime.utc(2026, 10, 25, 12), 'Europe/Vilnius'); + expect(spring.end.difference(spring.start).inHours, 23); + expect(autumn.end.difference(autumn.start).inHours, 25); + final week = GoalCalendar.calendarPeriod(GoalPeriod.weekly, DateTime.utc(2026, 10, 11), 'Europe/Vilnius'); + expect(GoalCalendar.local(week.start, 'Europe/Vilnius').weekday, DateTime.monday); + }); + test('archiving freezes the current period and stops recurrence', () { + final at = DateTime.utc(2026, 10, 5, 12); + final goal = ReadingGoal( + id: 'goal', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + startDate: DateTime.utc(2026, 10, 5), + endDate: DateTime.utc(2026, 10, 6), + createdAt: at, + isRecurring: true, + isArchived: true, + isActive: false, + rules: [GoalRule(at: at, target: 30, active: false, archived: true)], + ); + expect(GoalCalendar.currentPeriod(goal, DateTime.utc(2026, 11)).start, goal.startDate); + expect(GoalCalendar.pastPeriods(goal, DateTime.utc(2026, 11)), isEmpty); + }); + test('checkpoint grouping excludes obscured gaps and retains original IDs', () { + final start = DateTime.utc(2026, 10, 5); + ReadingActivity entry(String id, int offset) => ReadingActivity( + id: id, + sessionId: 'opening', + bookId: 'book', + bookTitle: 'Book', + source: 'reader', + startTime: start.add(Duration(seconds: offset)), + endTime: start.add(Duration(seconds: offset + 10)), + createdAt: start.add(Duration(seconds: offset + 10)), + ); + final grouped = groupReadingActivities([entry('a', 0), entry('b', 5), entry('c', 30)]).single; + expect(grouped.seconds, 25); + expect(grouped.constituentIds, ['a', 'b', 'c']); + }); +} diff --git a/app/test/goals/goals_provider_test.dart b/app/test/goals/goals_provider_test.dart new file mode 100644 index 00000000..3f587e42 --- /dev/null +++ b/app/test/goals/goals_provider_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:papyrus/services/reading_device_identity.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('manual, reader completion and undo use a persistent installation identity', () async { + SharedPreferences.setMockInitialValues({ReadingDeviceIdentity.preferenceKey: 'installation-one'}); + final preferences = await SharedPreferences.getInstance(); + await ReadingDeviceIdentity.initialize(preferences); + final now = DateTime.now().toUtc(); + final book = Book(id: 'identity-book', title: 'Book', author: '', addedAt: now); + final store = DataStore()..loadData(books: [book]); + final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await provider.logReading(book: book, end: now, minutes: 10); + expect(store.readingActivities.single.deviceId, 'installation-one'); + expect(store.readingActivities.single.source, 'manual'); + final completion = store + .completionChanges(book.copyWith(readingStatus: LibraryReadingStatus.completed), book, source: 'reader') + .single; + expect(completion.source, 'reader'); + expect(completion.deviceId, 'installation-one'); + expect(store.reversalFor(completion).deviceId, 'installation-one'); + await ReadingDeviceIdentity.initialize(preferences); + expect(ReadingDeviceIdentity.current, 'installation-one'); + }); + test('recurrence, manual correction, deletion and completion undo retain durable history', () async { + var now = DateTime.utc(2026, 10, 5, 12); + final book = Book(id: 'book', title: 'Physical book', author: '', addedAt: now, isPhysical: true); + final store = DataStore()..loadData(books: [book]); + final provider = GoalsProvider(now: () => now)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await provider.createGoal(type: GoalType.minutes, target: 30, period: GoalPeriod.daily, timezone: 'UTC'); + now = now.add(const Duration(hours: 1)); + await provider.logReading(book: book, end: now, minutes: 35, pages: 12); + expect(provider.current.single.reached, isTrue); + expect(store.goalDefinitions.single.isArchived, isFalse); + final original = store.effectiveReadingActivities.single; + await provider.logReading(book: store.getBook(book.id)!, end: now, minutes: 15, correcting: original); + expect(provider.current.single.seconds, 900); + expect(store.readingActivities.length, 3); + now = DateTime.utc(2026, 10, 6, 12); + await provider.refresh(); + expect(provider.current.single.seconds, 0); + expect(provider.history.single.seconds, 900); + await provider.updateGoal(goalId: store.goalDefinitions.single.id, target: 10); + expect(provider.history.single.goal.targetValue, 30); + await provider.logReading(book: store.getBook(book.id)!, end: now, minutes: 20, finished: true); + expect(store.getBook(book.id)!.readingStatus, LibraryReadingStatus.completed); + final completion = store.effectiveReadingActivities.firstWhere((a) => a.kind == 'completion'); + await provider.reverseActivity(completion); + expect(store.getBook(book.id)!.readingStatus, LibraryReadingStatus.inProgress); + await provider.deleteGoal(store.goalDefinitions.single.id); + expect(store.goalDefinitions, isEmpty); + expect(store.goalPeriods.length, 2); + expect(store.readingActivities, isNotEmpty); + expect(provider.history.first.seconds, 1200); + }); +} diff --git a/app/test/pages/goals_page_test.dart b/app/test/pages/goals_page_test.dart index 2c9a579c..11c1e64a 100644 --- a/app/test/pages/goals_page_test.dart +++ b/app/test/pages/goals_page_test.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/pages/goals_page.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; @@ -12,16 +15,75 @@ void main() { addTearDown(tester.view.reset); final dataStore = DataStore()..loadData(readingGoals: const []); + addTearDown(dataStore.dispose); await tester.pumpWidget(createTestPage(page: const GoalsPage(), dataStore: dataStore, screenSize: size)); await tester.pumpAndSettle(); } for (final size in [const Size(400, 800), const Size(1200, 800)]) { - testWidgets('empty state is vertically centered at ${size.width.toInt()}px', (tester) async { + testWidgets('empty state keeps creation and logging actions available at ${size.width.toInt()}px', (tester) async { await pumpEmptyGoalsPage(tester, size); final titleCenter = tester.getCenter(find.text('No goals yet')); - expect(titleCenter.dy, closeTo(size.height / 2, 100)); + expect(titleCenter.dy, lessThan(size.height / 2)); + expect(find.text('New goal').hitTestable(), findsOneWidget); + expect(find.text('Log reading').hitTestable(), findsOneWidget); + expect(find.text('30 minutes daily'), findsOneWidget); + expect(tester.takeException(), isNull); }); } + for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { + for (final size in [const Size(320, 720), const Size(768, 1024), const Size(1440, 1000)]) { + testWidgets('active Goals supports large text at $size in ${theme.brightness}', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final store = DataStore() + ..loadData( + readingGoals: [ + ReadingGoal( + id: 'daily', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + createdAt: now, + startDate: now, + endDate: now.add(const Duration(days: 1)), + ), + ReadingGoal( + id: 'yearly', + type: GoalType.books, + targetValue: 12, + period: GoalPeriod.yearly, + createdAt: now, + startDate: now, + endDate: now.add(const Duration(days: 365)), + ), + ], + ); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp( + theme: theme, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(size.width == 320 ? 2 : 1)), + child: child!, + ), + home: const GoalsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('New goal').hitTestable(), findsOneWidget); + expect(find.text('Log reading').hitTestable(), findsOneWidget); + if (size.width > 1000) expect(tester.getSize(find.text('Goals')).width, greaterThan(100)); + await tester.tap(find.text('History')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + } + } } diff --git a/app/test/powersync/papyrus_schema_mode_test.dart b/app/test/powersync/papyrus_schema_mode_test.dart index 7655505a..5de1374f 100644 --- a/app/test/powersync/papyrus_schema_mode_test.dart +++ b/app/test/powersync/papyrus_schema_mode_test.dart @@ -9,8 +9,11 @@ void main() { }); test('authenticated books table participates in synchronization', () { - final tables = papyrusAccountSchema.tables.where((table) => libraryTableNames.contains(table.name)); - expect(tables.length, libraryTableNames.length); + final tables = papyrusAccountSchema.tables.where( + (table) => libraryTableNames.contains(table.name) && table.name != 'tracking_staging', + ); + expect(tables.length, libraryTableNames.length - 1); + expect(papyrusAccountSchema.tables.singleWhere((table) => table.name == 'tracking_staging').localOnly, isTrue); expect(tables.every((table) => !table.localOnly), isTrue); }); } diff --git a/app/test/powersync/tracking_persistence_test.dart b/app/test/powersync/tracking_persistence_test.dart new file mode 100644 index 00000000..35b96908 --- /dev/null +++ b/app/test/powersync/tracking_persistence_test.dart @@ -0,0 +1,180 @@ +import 'dart:io'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; +import 'package:papyrus/powersync/powersync_book_mapper.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/powersync/library_database.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/papyrus_schema.dart'; +import 'package:powersync/powersync.dart'; +import 'powersync_service_test.dart' show OfflineConnector; + +class CapturingUploadRepository implements AuthRepository { + final batches = >>[]; + @override + Future uploadPowerSyncBatch(List> batch) async => batches.add(batch); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late Directory directory; + final now = DateTime.utc(2026, 10, 6, 12); + final book = Book(id: 'book', title: 'Book', author: 'Author', addedAt: now, customMetadata: const {'keep': 'value'}); + final activity = ReadingActivity( + id: 'reading', + bookId: book.id, + bookTitle: book.title, + startTime: now, + endTime: now.add(const Duration(seconds: 10)), + createdAt: now.add(const Duration(seconds: 10)), + ); + final goal = ReadingGoal( + id: 'goal', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + createdAt: now, + startDate: DateTime.utc(2026, 10, 6), + endDate: DateTime.utc(2026, 10, 7), + ); + PapyrusPowerSyncService service() => PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profile, user) async => + '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', + ); + setUp(() async { + directory = await Directory.systemTemp.createTemp('papyrus-tracking-'); + }); + tearDown(() async { + await directory.delete(recursive: true); + }); + test('guest restart retains goals and activity after deleting the book', () async { + final first = service(); + await first.activateGuest(); + await first.upsert(book); + await first.trackingRepository.commitTracking(goals: [goal], activities: [activity]); + await first.delete(book.id); + await first.close(); + final second = service(); + await second.activateGuest(); + final snapshot = await second.watchLibrary().firstWhere((s) => s.activities.isNotEmpty); + expect(snapshot.goals.single.id, goal.id); + expect(snapshot.activities.single.id, activity.id); + expect(snapshot.books, isEmpty); + await second.close(); + }); + test('old-server tracking stays outside CRUD and promotes atomically on capability upgrade', () async { + final path = '${directory.path}/account.db'; + final db = PowerSyncDatabase(path: path, schema: papyrusAccountSchema); + await db.initialize(); + final library = LibraryDatabase(db, () async {}); + await library.commitTracking(goals: [goal], activities: [activity]); + expect(await db.getAll('SELECT * FROM ps_crud'), isEmpty); + expect((await library.snapshot()).activities.single.id, activity.id); + await db.close(); + final reopened = PowerSyncDatabase(path: path, schema: papyrusAccountSchema); + await reopened.initialize(); + final supported = LibraryDatabase(reopened, () async {}); + expect((await supported.snapshot()).goals.single.id, goal.id); + await supported.enableTracking(); + expect(await reopened.getAll('SELECT * FROM tracking_staging'), isEmpty); + expect((await reopened.getAll('SELECT * FROM reading_activities')).length, 1); + expect((await reopened.getAll('SELECT * FROM ps_crud')).length, 2); + await supported.commitTracking(activities: [activity]); + expect((await reopened.getAll('SELECT * FROM ps_crud')).length, 2); + await reopened.close(); + }); + test('a downgraded server receives library writes while queued tracking is retained for promotion', () async { + final db = PowerSyncDatabase(path: '${directory.path}/downgrade.db', schema: papyrusAccountSchema); + await db.initialize(); + final library = LibraryDatabase(db, () async {}); + await library.enableTracking(); + await library.commitTracking(goals: [goal], activities: [activity]); + await library.upsert('books', PowerSyncBookMapper.toRow(book)); + final auth = CapturingUploadRepository(); + final connector = PapyrusPowerSyncConnector( + authRepository: auth, + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://example.invalid')), + supportsTracking: () => false, + ); + await connector.uploadData(db); + expect(auth.batches.expand((batch) => batch).map((entry) => entry['type']), ['books']); + expect(await db.getAll('SELECT * FROM ps_crud'), isEmpty); + expect((await db.getAll('SELECT * FROM tracking_staging')).length, 2); + await library.enableTracking(); + expect(await db.getAll('SELECT * FROM tracking_staging'), isEmpty); + expect((await db.getAll('SELECT * FROM ps_crud')).length, 2); + expect((await library.snapshot()).activities.single.id, activity.id); + await db.close(); + }); + test('captured repository is invalidated on account switch and guest data is isolated', () async { + final db = service(); + await db.activateGuest(); + await db.upsert(book); + final original = db.trackingRepository; + await original.commitTracking(goals: [goal], activities: [activity]); + await db.activateAuthenticated('one'); + expect(original.isCurrent, isFalse); + await expectLater(original.commitTracking(activities: [activity]), throwsStateError); + final empty = await db.watchLibrary().firstWhere((s) => s.activities.isEmpty && s.books.isEmpty); + expect(empty.goals, isEmpty); + await db.activateGuest(); + final restored = await db.watchLibrary().firstWhere((s) => s.activities.isNotEmpty); + expect(restored.goals.single.id, goal.id); + await db.close(); + }); + test('reader patches preserve unrelated edits and commit activity atomically', () async { + final db = service(); + await db.activateGuest(); + await db.upsert(book); + await db.upsert(book.copyWith(title: 'Edited title')); + await db.trackingRepository.commitTracking( + activities: [activity], + readerBookId: book.id, + readerPatch: { + 'current_position': .4, + 'last_read_at': now.toIso8601String(), + 'reader_locator': {'version': 1}, + }, + ); + final updated = (await db.getById(book.id))!; + expect(updated.title, 'Edited title'); + expect(updated.customMetadata?['keep'], 'value'); + expect(updated.currentPosition, .4); + final invalid = ReadingActivity( + id: 'rollback', + bookId: 'gone', + bookTitle: 'Gone', + startTime: now, + endTime: now, + createdAt: now, + ); + await db.trackingRepository.commitTracking( + activities: [invalid], + readerBookId: 'gone', + readerPatch: {'current_position': .2}, + ); + expect(await db.getById('gone'), isNull); + await expectLater( + db.trackingRepository.commitTracking( + activities: [activity], + book: updated.copyWith(title: 'Must roll back'), + previousBook: updated, + readerBookId: book.id, + readerPatch: {'invalid_column': 'invalid'}, + ), + throwsA(anything), + ); + expect((await db.getById(book.id))!.title, 'Edited title'); + final snapshot = await db.watchLibrary().firstWhere((s) => s.activities.length == 2); + expect(snapshot.activities.map((a) => a.id).toSet(), {'reading', 'rollback'}); + await db.close(); + }); +} diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index a3aa131c..91febc76 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -470,12 +470,12 @@ void main() { expect(provider.book!.lastReadAt, isNotNull); }); - test('sets status to completed when position >= 1.0', () async { + test('reaching the end keeps completion explicit', () async { await provider.loadBook('book-1'); provider.updatePageProgress(300, 1.0); - expect(provider.book!.readingStatus, LibraryReadingStatus.completed); + expect(provider.book!.readingStatus, LibraryReadingStatus.inProgress); }); test('sets status to inProgress when position > 0', () async { diff --git a/app/test/reader/reading_activity_tracker_test.dart b/app/test/reader/reading_activity_tracker_test.dart new file mode 100644 index 00000000..c59eeaea --- /dev/null +++ b/app/test/reader/reading_activity_tracker_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/repositories/tracking_repository.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/goal_period_record.dart'; +import 'package:papyrus/reader/reading_activity_tracker.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +class RecordingRepository implements TrackingRepository { + @override + bool isCurrent = true; + int failures = 0; + final records = {}; + final attemptedIds = >[]; + final patches = >[]; + @override + Future commitTracking({ + List goals = const [], + List activities = const [], + List periods = const [], + String? deleteGoalId, + Book? book, + Book? previousBook, + String? readerBookId, + Map? readerPatch, + }) async { + attemptedIds.add(activities.map((a) => a.id).toList()); + if (failures > 0) { + failures--; + throw StateError('Disk unavailable'); + } + for (final activity in activities) { + records[activity.id] = activity; + } + if (readerPatch != null) patches.add(readerPatch); + } +} + +void main() { + final start = DateTime.utc(2026, 10, 6, 12); + const pages = [ + ReaderContentCoverage(key: 'pdf:0', start: 0, end: 1, pdfPageIndex: 0), + ReaderContentCoverage(key: 'pdf:1', start: 0, end: 1, pdfPageIndex: 1), + ]; + ReaderActivityEvent event({bool ready = true, bool visible = true, List coverage = pages}) => + ReaderActivityEvent(ready: ready, visible: visible, cause: ReaderNavigationCause.viewport, coverage: coverage); + test('ready foreground time includes no inactivity timeout; panels and lifecycle pause', () async { + var now = start; + final repository = RecordingRepository(); + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: start), + shelfIds: ['shelf'], + deviceId: 'one', + onError: (_) {}, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + tracker.onActivity(event(ready: false)); + now = now.add(const Duration(minutes: 2)); + await tracker.flush(); + expect(repository.records, isEmpty); + tracker.onActivity(event()); + now = now.add(const Duration(minutes: 20)); + tracker.setForeground(false); + await tracker.flush(); + expect(repository.records.values.single.seconds, 1200); + expect(repository.records.values.single.coverage.length, 2); + tracker.updateScope(['new shelf']); + now = now.add(const Duration(minutes: 4)); + tracker.setForeground(true); + now = now.add(const Duration(seconds: 4)); + tracker.onActivity(event(visible: false)); + now = now.add(const Duration(minutes: 3)); + await tracker.close(); + expect(repository.records.values.fold(0, (n, a) => n + a.seconds), 1204); + expect(repository.records.values.last.coverage, isEmpty); + expect(repository.records.values.first.shelfIds, ['shelf']); + expect(repository.records.values.last.shelfIds, ['new shelf']); + }); + test('jumps only credit actual exposed pages after ten seconds', () async { + var now = start; + final repository = RecordingRepository(); + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: start), + shelfIds: [], + deviceId: 'one', + onError: (_) {}, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + tracker.onActivity(event()); + now = now.add(const Duration(seconds: 9)); + await tracker.flush(); + expect(repository.records.values.single.coverage, isEmpty); + tracker.onActivity( + event(coverage: const [ReaderContentCoverage(key: 'pdf:99', start: 0, end: 1, pdfPageIndex: 99)]), + ); + now = now.add(const Duration(seconds: 10)); + await tracker.close(); + expect(repository.records.values.expand((a) => a.coverage).map((c) => c.key).toSet(), {'book:pdf:99'}); + }); + test('write retries reuse UUIDs and captured profiles cannot write into another library', () async { + var now = start; + final repository = RecordingRepository()..failures = 1; + final errors = []; + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: start), + shelfIds: [], + deviceId: 'one', + onError: errors.add, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + tracker.onActivity(event()); + now = now.add(const Duration(seconds: 10)); + await tracker.flush(); + expect(errors.length, 1); + await tracker.flush(); + expect(repository.attemptedIds.first, repository.attemptedIds.last); + expect(repository.records.length, 1); + repository.isCurrent = false; + now = now.add(const Duration(seconds: 20)); + await tracker.close(); + expect(repository.records.length, 1); + }); + test('EPUB metadata calibration is estimated; no metadata still records time', () async { + for (final count in [null, 200]) { + var now = start; + final repository = RecordingRepository(); + final tracker = ReadingActivityTracker( + repository: repository, + book: Book(id: 'book', title: 'Book', author: '', addedAt: start, pageCount: count), + shelfIds: [], + deviceId: 'one', + onError: (_) {}, + now: () => now, + checkpointInterval: const Duration(days: 1), + ); + tracker.onActivity( + event(coverage: const [ReaderContentCoverage(key: 'epub:0:1000', start: .1, end: .3, chapterCount: 10)]), + ); + now = now.add(const Duration(seconds: 10)); + await tracker.close(); + final activity = repository.records.values.single; + expect(activity.seconds, 10); + expect(activity.coverage.length, count == null ? 0 : 1); + if (count != null) { + expect(activity.coverage.single.pagesPerUnit, 20); + expect(activity.isEstimated, isTrue); + } + } + }); +} diff --git a/app/test/widgets/goals/goal_details_sheets_test.dart b/app/test/widgets/goals/goal_details_sheets_test.dart index 463fc460..119fcf18 100644 --- a/app/test/widgets/goals/goal_details_sheets_test.dart +++ b/app/test/widgets/goals/goal_details_sheets_test.dart @@ -1,77 +1,62 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; -import 'package:papyrus/widgets/goals/active_goal_details_sheet.dart'; -import 'package:papyrus/widgets/goals/completed_goal_chip.dart'; +import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; void main() { - final goal = ReadingGoal( - id: 'goal', - type: GoalType.books, - targetValue: 12, - currentValue: 4, - period: GoalPeriod.yearly, - startDate: DateTime(2026), - endDate: DateTime(2026, 12, 31), - ); - - Future openSheet(WidgetTester tester, {required bool completed, double width = 390}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(width, 700); - tester.view.padding = const FakeViewPadding(bottom: 48); - tester.view.viewPadding = const FakeViewPadding(bottom: 48); - addTearDown(tester.view.reset); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.dark, - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - onPressed: () { - if (completed) { - CompletedGoalChip.showDetailsSheet(context, goal: goal); - } else { - ActiveGoalDetailsSheet.show(context, goal: goal); - } - }, - child: const Text('Open'), + for (final width in [320.0, 390.0, 1200.0]) { + for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { + testWidgets('goal sheet footer is above system navigation at $width/${theme.brightness}', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 700); + tester.view.padding = const FakeViewPadding(bottom: 48); + tester.view.viewPadding = const FakeViewPadding(bottom: 48); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final goal = ReadingGoal( + id: 'goal', + type: GoalType.books, + targetValue: 12, + period: GoalPeriod.yearly, + startDate: DateTime.utc(now.year), + endDate: DateTime.utc(now.year + 1), + createdAt: now, + ); + final store = DataStore()..loadData(readingGoals: [goal]); + final provider = GoalsProvider(watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GoalDetailsSheet.show(context, goal: goal, provider: provider), + child: const Text('Open'), + ), + ), ), ), - ), - ), - ); - await tester.tap(find.text('Open')); - await tester.pumpAndSettle(); - } - - for (final completed in [false, true]) { - for (final width in [320.0, 390.0]) { - testWidgets('${completed ? 'completed' : 'active'} goal actions fit above system navigation at $width', ( - tester, - ) async { - await openSheet(tester, completed: completed, width: width); + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); final done = find.widgetWithText(FilledButton, 'Done'); - final delete = find.widgetWithText(OutlinedButton, completed ? 'Delete goal' : 'Delete'); + final delete = find.widgetWithText(OutlinedButton, 'Delete goal'); expect(done.hitTestable(), findsOneWidget); expect(delete.hitTestable(), findsOneWidget); expect(find.byTooltip('Close').hitTestable(), findsOneWidget); expect(tester.getRect(done).bottom, lessThanOrEqualTo(652)); expect(tester.getRect(delete).top, tester.getRect(done).top); - expect(tester.getRect(delete).height, tester.getRect(done).height); expect(tester.takeException(), isNull); await tester.tap(done); await tester.pumpAndSettle(); expect(find.byType(AppBottomSheet), findsNothing); }); } - - testWidgets('${completed ? 'completed' : 'active'} goal header closes the sheet', (tester) async { - await openSheet(tester, completed: completed); - await tester.tap(find.byTooltip('Close')); - await tester.pumpAndSettle(); - expect(find.byType(AppBottomSheet), findsNothing); - }); } } diff --git a/app/test/widgets/shared/bottom_sheet_expansion_test.dart b/app/test/widgets/shared/bottom_sheet_expansion_test.dart index bf2dfc19..09d04cca 100644 --- a/app/test/widgets/shared/bottom_sheet_expansion_test.dart +++ b/app/test/widgets/shared/bottom_sheet_expansion_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/models/library_filters.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; @@ -16,6 +17,12 @@ import 'package:provider/provider.dart'; import '../../helpers/test_helpers.dart'; +GoalsProvider goalProvider(BuildContext context) { + final provider = GoalsProvider(watchClock: false)..attach(context.read()); + addTearDown(provider.dispose); + return provider; +} + void main() { Future openSheet( WidgetTester tester, @@ -83,7 +90,7 @@ void main() { } final longSheets = { - 'goal': (context) => AddGoalSheet.show(context), + 'goal': (context) => AddGoalSheet.show(context, provider: goalProvider(context), initialTimezone: 'UTC'), 'physical book': (context) => AddPhysicalBookSheet.show(context), 'shelf editor': (context) => AddShelfSheet.show(context), 'shelf selection': (context) => MoveToShelfSheet.show(context, book: buildTestBook()), @@ -182,7 +189,11 @@ void main() { }); testWidgets('desktop sheets keep content sizing and ordinary scrolling', (tester) async { - await openSheet(tester, (context) => AddGoalSheet.show(context), size: const Size(1280, 800)); + await openSheet( + tester, + (context) => AddGoalSheet.show(context, provider: goalProvider(context), initialTimezone: 'UTC'), + size: const Size(1280, 800), + ); expect(find.byType(ExpandableBottomSheet), findsNothing); final header = find.byKey(const Key('bottom-sheet-header')); final top = tester.getTopLeft(header).dy; diff --git a/app/test/widgets/shared/bottom_sheet_layouts_test.dart b/app/test/widgets/shared/bottom_sheet_layouts_test.dart index c8fa17ee..6c2b800c 100644 --- a/app/test/widgets/shared/bottom_sheet_layouts_test.dart +++ b/app/test/widgets/shared/bottom_sheet_layouts_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/book_details/bookmark_dialog.dart'; import 'package:papyrus/widgets/book_details/annotation_dialog.dart'; @@ -16,6 +17,12 @@ import 'package:provider/provider.dart'; import '../../helpers/test_helpers.dart'; +GoalsProvider goalProvider(BuildContext context) { + final provider = GoalsProvider(watchClock: false)..attach(context.read()); + addTearDown(provider.dispose); + return provider; +} + void main() { final book = buildTestBook(title: 'A long book title for checking compact sheet headers'); final launchers = { @@ -23,7 +30,7 @@ void main() { 'topic assignment': (context) => ManageTopicsSheet.show(context, book: book), 'shelf form': (context) => AddShelfSheet.show(context), 'topic form': (context) => AddTopicSheet.show(context), - 'goal form': (context) => AddGoalSheet.show(context), + 'goal form': (context) => AddGoalSheet.show(context, provider: goalProvider(context), initialTimezone: 'UTC'), 'bookmark form': (context) => BookmarkDialog.show(context, bookId: book.id), 'annotation form': (context) => AnnotationDialog.show(context, bookId: book.id), 'note form': (context) => NoteDialog.show(context, bookId: book.id), diff --git a/app/test_driver/reading_tracking_driver.dart b/app/test_driver/reading_tracking_driver.dart new file mode 100644 index 00000000..b38629cc --- /dev/null +++ b/app/test_driver/reading_tracking_driver.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/app/tool/preview_goals.dart b/app/tool/preview_goals.dart new file mode 100644 index 00000000..f34b3cdf --- /dev/null +++ b/app/tool/preview_goals.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import '../integration_test/support/tracking_app.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final app = TrackingValidationApp(); + await app.initialize(); + globalContext.setProperty( + 'trackingValidation'.toJS, + (() => jsonEncode({ + 'activities': app.store.readingActivities.map((value) => value.toJson()).toList(), + 'seconds': app.goals.current.first.seconds, + 'pages': app.goals.current.first.pages, + }).toJS).toJS, + ); + globalContext.setProperty( + 'trackingAppearance'.toJS, + ((JSString name, JSNumber scale) { + final theme = switch (name.toDart) { + 'light' => AppTheme.light, + 'eink' => AppTheme.eink, + _ => AppTheme.dark, + }; + app.preferences.themeModePref = name.toDart; + runApp(app.build(theme: theme, textScale: scale.toDartDouble)); + }).toJS, + ); + globalContext.setProperty( + 'trackingRoute'.toJS, + ((JSString format) { + if (format.toDart.isEmpty) { + app.router.go('/'); + } else { + app.router.goNamed('BOOK_READER', pathParameters: {'bookId': format.toDart}); + } + }).toJS, + ); + runApp(app.build()); +} diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index a9f33050..88f382cb 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + FlutterTimezonePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTimezonePluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowToFrontPluginRegisterWithRegistrar( diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 15ee8f8b..82e35adb 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop desktop_webview_window flutter_secure_storage_windows + flutter_timezone url_launcher_windows window_to_front ) diff --git a/docs/goals-tracking.md b/docs/goals-tracking.md new file mode 100644 index 00000000..f07a2af3 --- /dev/null +++ b/docs/goals-tracking.md @@ -0,0 +1,89 @@ +# Goals and reading activity + +Goals project progress from the profile's durable reading ledger. The Goals page, +Dashboard, and Statistics use this same source; none seed example progress. + +## Reading and completion + +The reader reports readiness and visible document coverage through an optional, +Goals-independent callback. The host records foreground intervals every ten +seconds and flushes when leaving. Backgrounding, settings/contents panels, and +other routes pause recording; there is no inactivity timeout. Checkpoints use +stable UUIDs and the originating profile's repository, so retries do not increment +an editable counter or write into another profile. + +PDF pages qualify after ten seconds of exposure. A jump contributes only exposed +pages, including spreads. EPUB estimates use normalized chapter content coverage +and the book's page-count metadata; screen pages and chapter numbers never count +as pages. A cover-only chapter or a book without page metadata still records time. +EPUB estimates assume equal calibration weight per spine chapter and are labeled +as estimates. Coverage is deduplicated per book and calendar day. + +Reaching the end offers explicit completion confirmation. Library and book-details +status changes use the same operation. Rereading preserves completed status, and +undo appends reversals rather than deleting activity. Manual logs support physical +books, dates, duration, pages, completion, notes, and audited corrections. + +## Rules and history + +Goal definitions capture an IANA timezone, creation time, scope, and rule history. +Monday weeks and calendar days/months/years respect timezone transitions. Activity +before creation or during a pause does not count. Target/title revisions affect +the current and future periods; metric, cadence, scope, timezone, and reading-day +threshold changes require a replacement goal. Recurring goals remain visible after +achievement and advance at calendar boundaries. Archive and deletion preserve +period history; activity retains book identity/title and shelf snapshots after +book deletion. + +Duration is the union of eligible intervals, including concurrent devices. Pages +union stable document coverage; confirmed books count once per period. Reading +days default to five cumulative minutes. Period records retain rules, not editable +progress, and can be reprojected after corrections arrive. + +## Sync and compatibility + +`GET /v1/sync/settings` advertises `tracking_schema_version: 1`. New tables are +`reading_goals`, `reading_activities`, and `goal_periods`, with an owner-scoped JSON +payload. Older servers keep tracking in local-only `tracking_staging`. Queued +tracking after a server downgrade is retained there while ordinary library uploads +continue. Capability discovery promotes staged records atomically. + +Deploy the server migration, PostgreSQL publication/grants, and PowerSync streams +before releasing this client. Versions are unchanged; merge feature PRs into +`development` and use the normal release workflow. + +## Validation + +The isolated validation host uses real EPUB/PDF files and records actual reading. +Its fixture-opening buttons are not part of the production Goals page. + +![Desktop Goals, dark theme](images/goals-desktop.png) + +![Phone Goals, light theme](images/goals-mobile.png) + + +Focused regressions cover calendar/DST rules, cross-device overlap/coverage, +completion undo, corrections, recurrence, profile switches, SQLite restart, +atomic position/activity writes, and older-server staging/promotion. Shared JSON +projection fixtures match server aggregation. + +For real reader validation, serve an EPUB and PDF on a local HTTP fixture endpoint +as `/reader.epub` and `/reader.pdf` with CORS enabled. Fixtures are not committed. + +```sh +# From app/, with an Android emulator running: +../../tools/flutter test --no-pub --no-enable-impeller --timeout=3m integration_test/reading_tracking_test.dart \ + -d emulator-5554 --dart-define=TRACKING_FIXTURE_ORIGIN=http://10.0.2.2:7311 + +# Local browser inspection, from app/: +../../tools/flutter run -d chrome -t tool/preview_goals.dart \ + --dart-define=TRACKING_FIXTURE_ORIGIN=http://127.0.0.1:7311 +``` + +Manual logs, completion confirmations, and corrections share the installation +identity persisted in preferences; reader records retain their source. + +The preview uses an isolated guest database and exposes development-only browser +inspection hooks. It is never included in the production entry point. Real +multi-device PowerSync transport testing remains a separate opt-in integration +lane requiring configured local services. diff --git a/docs/images/goals-desktop.png b/docs/images/goals-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..652a63e13d3aa88dac2ce5b4b68b1c0aa4e17097 GIT binary patch literal 73566 zcmeFZWmuG7+$WA52#A#QPmmT6=~iIqkVaBKx`&dXOj1Ckq&tQb1%^gxLAq;(hM{|a zVVFJmd!GM)_qukk-F^4G*m-qdFx+wObH3kC9YUY0D-hkLxlKSoK%}H7r$s<;ErWpI z%IeL_;3xUiy*UH~cL|i_p6R?!*_gRzOto^QV>i1l`YyT7)i#2X6o+iST{~i!1N4~l8_lRBcGV$MojDwpZe-GXaGSB`!AW)9_UwjEE;p8LOr82A)dGOI zcXi%<%F4NJ3(jP0XH^cEtdgl^B?uf=$?AWq%4t>_mY3({=y*I*q%c~Fm4_w&Nog#X z`MAccFh<^DZ@-4a`X{BOfxp{6KPO=!Ab4(kap&yLF;Xw|noMuFY}X09r1lT-L*Wx= zF;Vf4&YaHZt-3|R-YN>S`hVBCGu`OV`G8Mv@#f|v5kIdb|8~N_pb;E)$a;Fpiap{* zRrxnwB_IeRy>x?spsi48Ve4?Ms?KvgXMXt_TRzOpuhID_zVD_;nxN}X0Z-4K3HM+h zm}$I9UO;WFSIMYa@{xqMbHc$9A72%D`mI}h_rSfGUZn%0Rg|X&du3(ignXi}xwkVn zf@Pp`74Ru%=iu`D)qez1RckPm%4RHTaZXhe95s+ zvF06o`Q*ax0&P%`wcaR&&O?fFrF|;D&tJZ1mb`PE))Vgpiu?7O_bf34plUfi4UrQDusGQLCSjr!&>5t}iae=+BH4!|fQHTR^bV!_6+Uq=e1e;PoZayCK)YE=80Wh6mL3S19#K`X(s5gw)G6yL+pfhAO$8 z!rH?IV)Cn;QRPqXq)7d6+TBh_5?*(ooh_6nsuK|%mGp!qPsc3HWfy7Z7jF&v{7I@b z3mf?Ksp1-8hxAb`N06FY2_0t*W%Z?NTV%Srm131f;T{Wh+c!E#0?y2vLLyp!z7N(f z?$-M=vAjbNou&vz=l;O^D_}0=XMHLA$LNfg#O<`?zLXm@=D7H!zM5Y=x6$vh?YAPo zgaj)or321M4h|zIW^SBaVZcew;{Rc0Hc{{3& zXn0r>34`&-;4oY(xAEXfbo8&ir!ZJyR8+7*@xgK@RteJ8RF|_6xLI0JLGJTZPADKC zjp^In79Jk%-03sO`S-@gw9rsKp{ls0q|nfEXzhcgRwYfQDt6Z^x2M^9dxe~JOMT`BvTP1X{D0e71Ec({v|T9F*@3(apLgsQ(CI9loY4UZ>!s;d;1SrIvgC3oM!b}BRNJ76XHm7&w|ao&Yhqw6kd7U=~=b|G(9SMP$RP_Op4DC?PL zHVWDc6umT)+|Fj{iYsE$`Xuh%Ct=j|U6vcXqz@my{`qrfcYhJRsggjvShBOa8^Ni& zru6Z1S~MjE{dX7CUj4_DG}j0Ge7}7t6kGAzM4CQAk2peZI%{ct$q`oAxVsz2rBjfG z=%1{8_x5dKI{xEjn}IRmc$Xq2HMQTpy@pM5!NtXo+J+18pOx8Y4t#8(it# z_HwfuDBC7)bFkpjD}K>nXc>qPe*4xG8X6Q7pOjQ9f}m<~=EjEidD6nGI*S*blwZ3qwA2LeSbCNjNquK4n^qsyY#Xr(l zQCIID6V_B692lTt3Db1n|GxKT{H3_~N@u}8=pIt>){Ze*$4}$xZN9IrAEf7%mb#$_ z10aGk(>%1IS89VSg5~5E9b2*$#%P4s3iE9$oUndqyqRA4XBik$ij+Dm+j1tb(9T{3 z%^no($mBmbYmGsPglNWn&EeFNQbdVYa1J3$Nm>7h8kzpN)PlKd@)%xBgp|0Zo41D zeJw3p9CTb*dk35FZnNOl+H^X)D)Uth=MER+MPjHDyO+3bES26pzZ6v{GLrmwYwYXz z4rm+6Uk#RtL}-LonW`QzF@3X+um+t8OH%w9S)i6|t&vt?v6f%tJR?TI$Y^DjS!=l@ zhHyz2^S7?cQV*x}9~l<3uxO?gTFuF}bUpne#NlmL38fv+-M=6HWT{o3-DZ9Kn|Z=T zRUEI$*l`F%O4YcvG(3B+ZrO3R;f8;w60sb|SyO*tR}6-#;O|gP2bk=iH3VnB&~RvdTLb! zt#^1-l$1YgiYu3Pa3Aygi&c!L#{sS28dHFbj30wkt+Re(7=S#=v0)_odKwz022pEkD=yvQ7nw2<&?UO=SPuR6X+0w&eaO;GLl8Y9 zBLyujDfyYeKeN$MQ5`J`WApP&EcGfnj!qj7FRtN&Sne?#E!EvVjo^xEV1USduzB@L z@%eS8uqT3oMLOlsO+!yciTc3#MLu*BDXIL7n3IXIApn4mR#v=oqB1h|+tZ@H>qozh z5E@oCHY%@g+y!+CoVTu*zRu3hj$YJ@osaL?JLur^G25xC_j!5CTaw~AW4dLg!FdsN zV`V5!natFtZ!2dfGXUeqKA(hB)6jr|2d1oE1g#+WOqDx0z+g#NbK~OT4#(FubCu(q zx+YJLVJc+>#-jNQGi(Rw!_WqQ!CPfnel?t>rOv0P?dEgmpu^r%=KafsW;d-~z8tT! zX>t-5%FoGBd>+u)h@=KutrRY;jpp~|utwB5*xD++_Do8eRFCv5DdB2w zhkW>WqQsv2mrA`s=!5adw#<^)Ld8Ajd}VEIZ)TWJ`e(NM!(hn2i^-*IZ)r14>hR0+FbSVEmVlCv#nZ)hWsUx<3EW0X&B@Py zHJ-i+NC#m5g^Zot-(^?d@afa1?5ymEu}qqpn(gu**A&bU>UC+7QUbz==$&N)!%5F| zu~PYnynD*@|M9H3g;_~)aV(kSzq`70bp204zeYsJb`ZCK$5DPg#5u0NJ?&PZDE2Ux z=Yqo!5bTWFO<9NhaA&>+X=%%atm|E2x%d@3bp71G_oTUPi0$>Z0QlZ>b|P9YyD_hj z4_U)qPA|Fjg>U7C)P5N39BK31$#_N_#|glRgPom~wKeNQ*6Y`9Ry5)4el?uQIy|2z3FPNu}p1Mt}`5oIE96S;2L z@-uawWN3Lq*y*J7SgGj`*6rc7k;1-2Tue=(Po@2c)O~)hiLfPr#|d@x zTf}2L&XxJWz)08ClVz4&ibpV+mL>_%1m))e)1y*Pjlc!})dF-24>pJN6%-WiRHwW4 zB{<*2NNklD*C;bM4`kJVN=(bRMs;4ad=k4Ys07}I(#%YjhB!|BQYTwmPMi%X>A<`K%yU2RMT7NWRXxi}uZdBG zZR&wgO~B?>^T$9%mv?cx*VsC0!MMX*V>w4sQrm2x(7k3<0N1zXtp?xgBs4mCs?=mL zm(K?rQKcpr=NV4Df`YQLvaGCo>7Qp87JgW*kH+NR-ufh(%PlEMO-!Vw zrNZvawJdSzhiSm?($UdL-IvU>A1RcVm$$dKhr{8SnVBoS>3IL$3&2Lu);Ti5{S&Y0 zp%bD$p$s@=3krWUpNrGtprAWz?d*RfDX9zNnwZj!L4NSZPLJs51{1HdG}IDOl?vM% z7uoCHK+n8%G22|LWWE>6cu4_0@a@@^X!4LtW@acV3E2t_e89DMjYNSOn# zDJx%UYxT*(tC^!(|eO%~VpJl_gt>@^eNZFrtM zS|Xjg2RHbbC8wa!A*J7wB&ZNY&kR~(hJk?dw9{~B`nz}a0cpZefN#3vOBOf%HDKbl z4(qu&50zbpkLU?=5C$)jzeG_ydi1BtMf~7|YC%8%8A>K^G}ZNY<_~6DMwXO7kgfV=XkF6&kw^@R{BgF(4&u0ITtlmp)XC;f299; znNRq2wWMJsB_-@}_KP1Ye%AK(pzh!{5lxtliBjvH6x3v%8dgqku-0>-US$VEI9av% zIZET{`|G7aL8qXSHNaNtpRkIFIpd`;Mu@HCulFk*#)<>3P}Q6d<(~%=r@@YQ;0+B< zW2GgW;*BRbOD7X08Te0r5oMAqSFZT{BPDgNyG+LY*%{x61FtJK_F!Qn-P#|snb{J2 zw26}5FDeT-@eHuHFZ?=;a5016YCYG)R*;1IV5MOcJ{>#YP6?@aoyRLQ?_jam@Vzh! zPBHzG(|3YZ&LdrU#S!Ly`2skk!vFgIhBl zSxlBvsba9zZ>IU`rnT-XZzCh)iyU;NQUCA>K6<3JNJS!%9%4@FHC>1Orq#CRW22#_ zW>o*WAv+uQhgX{1Jypanh_wOj%4-}m!9QjEvPE`e^H5jeUQe@IAN#kMNoT&4dE3-*M zq`0qml^Ddu2o}KM2wUXX80MTrn)qIJ9p;hg8MaN&`HFffl7@1t$#Ugg12+CQ$?)SR zoI?fXR76DFe4L|kU+-g=#w26DK-C8z-v+-!P?z;%qob={&K-A{3b%cO!{cOG^!YN% zT;}H|_kIseq5P$vmL4r&u*Jn;nJyp0;^QL^44YfBiRmSB@B1Hg8tFo;X41teSW?C6 z5|fk3p?1B!#Fh4RZ$PDAXd(JO2*cvpiUw4^> zm%gUG>lzzSVptnNEqOYzw?5`__4+ksQC|738U*BF@f2eFb=72KYTdM9Exe$BUChe3 zE0DxeP;R!q}Y;5bRs{)SwJ1x6pl-w_hzaI1y<0ta~Et}0HN4FP_ zm{nJ9m}SfJM>}`kY7W%NhH3Bm%+4zN2=MbSoa0oOj0;|xHCVLKWVO!tXG?HAcpxSy zi1z6Y4UPDGgGO+zFgttuWcE88UTJ*q-n@36swki9{_-tm*&i=Z8;^y#ib_iI3pslu zN7{bd>*#!|sp-+)7QYa!QW6y;Tp&z0qK6`;?KLe#Eq>P6U*T3u#bS6l3t3ni40+M_ zKBuN8#vS$<=y}B##v&GVJvS#|%YROYXmR^lDc`Y~iq7M!}@$IYQK1jeT(C7r=iMZnx6VnizNopvxznPIs(!>%1| zj0AKX$sVrPvETOe^t28*$+XakG~D2Kn+OF_D6TqUQC08RI8f9k`ZHd1)elV0741Kfl-yT5sQE zAKWA+j=yn-h-g2)?A%ffx$?dgYgrN#qm8@`bqTMis952C(YON9*4Ea1k`aj4WLBVF zT@N?k)zGl8$SPnyryd+0{6c89HujMa5QM=Z8;xLPVlgo>;+n}yCvn(fL28}1({zOr znCDMPKMxVbr8LrtqU9^?BTz4E9{>fjsL0`K@|2w1)h?DjSr!>t|AqN19o-Q}N4mXs zM&7uTlp)XgVYE|YBZlJRo+Jm5|H-eI`44gN^REa6i&ai>a^~bb?DFR`)v*2QvK61K z)W*faqO|x7ijPG+>y9VtO|4to3ivh1xsqUOZJiPuo061dZI{T;8HtBne{g+xWW;`~ zG+?na(XcWVa!0cU`Z0nQlargbw26B_rXu2h`d#Gx=NM9m_Z>pQ{ctK5&MqA8J(u32 z{M=mhI;}yu1wdn)xzqc^<*M+T?6ri?CU!p$47PwJTRhERD`eH(IQYq~<^V0WbFrGh?M?rlSoiV_5{K--j{s0ziXkV#GJEwA?9-NVa zetvxg9p}rYI3*;z2=(_T1CTF)XJ@T4o2l)wV7Y|fBFRa|(Ei{dZAb1jRjQ(B>do^l zWk>C!bZ_$y3RmNKF@3s{^qVp!AGCK4>K`%PTPeiX%gSl=DRNRi`=ybK2&$^mLp7$p zdyn7xZDCVcix>%$+m+w6$d+I=EvY zBcp$FiX;@CNbPb?88?ftOJ8WpxS@b{>sv5=`SN9rvFG}*Khp9^8U-1drG8`m+qZAu zzki>ge=g7fXkcxvD3ONoG|Kz&(JE3_8j;+d$DOQSY|McZ4hRV7)_ar?tPpvB_q^Yn zNdrBZTQvVR50rw}+BCvjrP(EjSLnrf?o(ZN=L6JG#C0OEvEm6CzO{t~xB109A*bs# zm2?@|*^D%6M%|ThA0a37;_hjauFD(5q<$4pT6SsHY_CnB+S=>9CJa2fdOwQI1N#fe-N0C_np z9rXLU&ap2TBz-HT{f-Y@2*-eEENHJ&=M#Pk1vo~uL7j84ksl&Qakw_3s-#A7>moF*6p$p4u(ar@$MN!m}wL6zOvK69??MDGm zqwg=nW2DbNyVxi6nk>FGLO6F{(bJ0$Oqgy=E&Co66r^*La2ap!Vv~>*KN>jurhcht ztk`Zm!@P$^Q1w#;a2IAbH!pzitQu{9U?CiSNQVRd*#kT)dE(_J%MRS*%H;@%pl%q zx=7QLeCS29Yw6*!+B01t1Ec7U4=gOoHXErikK;VV;TU1QsLF5bk>Uz|d()x&W*+Db zR(L`Eso&rt2lIYp*k>8JdtsWS0v_8)y|cyS?&fC4k+q4i0q2=1#XfT1RqCO0 zJN*3ova+%lYEQl9ypHt4VTG=m$IIvY_0fZYzzikd)t=1(hG+Wn9d1`GdDtWszgTxm zRaK-k&h;OrC+udagj7di>EQlGRjH)=c%Hvv^idc)KdH1hE;~epGPbR5hqHEF`LRN* zO|G6kz_PuO6`7Tk2tNdGknCR8(!j^2G>$YjK?#_j?8Z<0`7z1KNTEw|Ma2P%vUC%f zU2;>dzTU6QxMt?XRX!HZhppJT>}FHicB9BQ**Z$f%7&HJhp|dAhX)6S2>VLQsiXC= zGVJl`t5>hSCMWBAxfWgtMnt-drj-<|z+MNO@TQ(b+;VYvS+ zkc8NWyYGj8N2FM#0}$d4H-`{@K6PCBWmj+9&@X;o4%uIx*VfsA?aA$G-M<)M4u|uv z9C37lf`@(iCE+1A>-|?^K~&@YY3h?7Pa^3|xEkW*qtcw5o!t-Dq8ArG&lb9&!s_zvQmF1+;C!8S_^J&2y!auB+xQ#1bmA}n=Hr8u$=iso;3(XWj9y2&N z=(RCjW4*~3_QfBI5iUSwsI89zA%}=o;I1^(2SB^vUAgSL@BUiTp_V2aZb$!y9FKOZbf+31`pxgaD0|4^p}0 zD5`EvHvkCwGFUn8&Ye50GJ``y%*@P?<2~#=glXY3c*f^zAgN(?rr7lph5v<-Ze)x5 zKr1{`W-6{#=Inb5vT^oN65*Ww<+)+O_~f_}_yYo@vZX>`wbfM6*RQs>az6s3{lSfq z_Y9XnHEcKjt-JMB6TGlcnf(b6)58<|p!<}PK(#o3f8hwdL3HP;V58{qDl(d4Zcw0^ za>)K`6#*{mI^7hgDKk**zfyq>RlBE5^&i@FTwH3K|LxpQg@uM-QmYhQyAGolt7jEC zhJO(5nOZ@1~0-#?4HASh$8;gh*_>&k>%oAXVYkS7)!|`=+M^Dfjxy$<$X_2x3RD&R(o;d z$`xp-@K12PNE1R{6S03X1(;^R$S3lN|$Qj$~^dwo3KB>6hN`THCkXg z)Q94glvMj`pWHNf;vFpY$jMQC` zedK>@@BleBFV8xTQ?JydXzP72Mk7Un6(=7GYn}vc0*v7Q zxi|H1Xe~pazrSBuLn98UR9MKg*WKII^<{_nUt^4*YdcjYAb5(tFo!Qb|DCV-U*}8tJahCJaQM>f^BO--BT`mvP|i@He3W=OGgKr z1k)3#2cq2E{9Ifsbh^#%9_U3OZzRdRG}#=>$w{&H_IADa(IYPkz9L;mB$=^K1% z9fFQiRLFT+fSn!m2F(k@`OSQrth~wtqh#on>vxX!3H>%Gi<36cttPs7p zSB>u&vly$=W#xDI`5iUx`E97o3%`Y+Ed+FKH-`Z+A)&}dTJ{&hE+!--gk*sc7j1)p zoa%DqVD_gPh+2Z7M#FoSjGR<4_blrk!|8&H`^^{!S`Pyl^!=TkMR?Y0+J|gxCN(h4 zGqu0D%;%3ltGESnOf(}VGA$N4^CNJ}6;Wwiy~V^DOL%%%-FTv1smoqXyul-FZw_(N^IYNqZGq>02}8#_x}36CB<>WHLfqaf1g!)l~U zMI))B(qEg@1>go2I{J$KSz&&Ey^*b<GdlO#mZVi9a({YnCM_Ieb7Qa?RjuzLc8$Imvy4@@aAgkjSFcjy<2i6QYP|E#Ql0-KOJi~mK_DQf zu#l35<_qHS11VE8)YCgxuO0&%)Rf1=s}$s4SOy~^>BSIG2mfv4j9RjA$QJ<2feEcw zYH95puyH&<#x}z!8St=~#_b93ZNRUxOG~}Zn``Qe*j*n0bhEs?486uz79U?#z#6o< zxe2+M<$;!$T4)c*C@MnO4rFbLdx_|kN?7gXpeT7y?Rx3>?Ai+Bq;2sXkQD`GW`KE?ou#6(;>Y)#G1h!quaD}&SKYd}r7RaL_9+2YD0Eo#Ago<+16f96 zIa-VH(Y%zK|Fk}Re@cYI;q*#gxa|#x=5{C8+S&>@ond06cuecRIbeE=#!BhDrIQll zCbC`-YOWega6Ec+HrH~)M_+SRruCBr0H9S&GmrTN32)s3DZsD-M=LvudwY6K!rsT& zZ8HgP(_Kq*&7Z!y5fKqUOh7nJaW;q3i+f)O<9~9(@~|~riQ_j15{ay>tp)N!jmODg zO8ScPbOk!urD6g7=rpS$r zef9qh!~$iUw|Y+!&-?4dQl6)anJqV#zKkh?_Nbs3rok+mUH>sDDTx|QZ87`vmp}ca zx9xC7bXsXzbo9U>?U*|!EiHN-U1m^aSXvB*+|h!G1SCK@uguNO&A)2Y%1yxv}T`lZHN!@S>o+p-l%l7$@x z3Uz5g?v@bWD7|zIyiMY&swzrzg_qA~+rn3ph3qC9j5ItwD+#IkR9wpY`@eBdRdLGS zAfY*MOij1$lX72n#CmNO>cIDwK2ZaV*_$TL=JN5w2RStASx{J5NWksmGVD$?vop6* zBpqLKp5qG)*_IaR(C8>>VB)`xMe_1wma@TrY5|d2OhQ8P+S=Trq6@i#eW~I_Oz?wk z;peaDKmZj?72gOC*)IPsc>{yjBb>cqU*w5sjbh71+?Qh|VB1I&Qa&)10b}T70VosL z?Q9yUpA}M53MKrGf0lo@Y-FiR7a7fhbn9^&eN_11E4A}Sxbo-6khg$_PF6T4gS6;z z=P!jQMhFBV=sf+9>B$pzc10~~Vq)TS^)%@0?Ax-&-#$h?j74L>k$8INl?{GhdDda9 zjD>|ox5N;_;0c935$10S#Gn6A5qCdQbyal5l+MUqbb{;f5D*Lr|IdEH07^aR0rnYX zF)4IrU%PDjQc2U97FI`oM0JXoGS7TYm2&^Cu^nL6dwkkEBR;QFrM_7^Ffd?*aEZ=m zknjM~xw=(X>_nhznK5)}&5G1VHBsQOy=Q%?dkREnY-egKqoYYrD3je2l%E3|0@tQO zm1bi1mXK7iEdj_A+4ZKo=c;v?-7B;fhww4*g7q%epQoqX zPrmg!7aRPsU&TUk&I$0B*pKqg?Py4P& zg?m?6srI7eXS^-|%)8XA$*{ywS5T1R7JGn-QFy7CipLq16xv*lVyXC#tJlhS`G9sE6a=&jwA^=Jvb-+7lX@R zrOQ=&EjM0jG6nnrC23;vCc{!su(){7FV~1(6tbs$plN-)d>JR~Vrte{uVxkeUBaEb zO;$kxVTt7tw66|nlH8?Y|J&bonP%BjTMzd=14}}~Bb=8;3yLdm@!bfE3=yIcbbMkx ze09d0c zq(3(2%l;6FbOqF5X>N|s;(#4qeTQ12+P}^Vh%U8aVjLVCbEKp;y=gD%Uxn%zQ{s+% z8)`jy&1%jD*wPi-I$K*87edJZmanVv-4-h)3$=Q zjc+qE&9$_&JX9;P9t#MlYFPsprO+OKmCmZ9yRe|(!-o&AtgSe2laZ0-dVdPh0hV{D zNpyS~0~I6h6JlR+ao@TJf`wyW&(P$%!q1N(YH0#+Kz1%&x&*?MaYz`b247Fn`ODVJ z79v7ILY!ip4<0zSC~ zFm2(;rSK!|d{w)pQBHpT{ncGdm3ST^Lc*bu5qkWuYG;$WQK%zAZM6US?I zzTcOZ`1LDTL5|}U<*({CFV@C|sZY?=)!dwA860Ub+dtQW!tFE|e~pb5a5?7%q91b* ziOY0>X3KRV9+SpL9Bk2MIeB@M^8fg+fE}bv`vYneENHu1KPqt%uCAqn| z@$q^xGR@f?QF^6@{_962^`5Z9jfp0grtd;R_JxI@gy#3A1bB%8#Av39t&cA%0=W+m zL>GZKdSZ_NdJH?YSSxQ)0FJEp_5^MyMKtV1BmSta%4rhB4vyA_6gV$~x(dRapvw>B z`246}VvD378ydncPq~1`mcyPR>K`P6r=m^gRstLh#M?|7Eg(o2VXMJKfW~O4se!5q z>5xO!`|Nj2{Mb)(87noxzUQ-~=hBbIEYA3P1OH~5Yu0x~X_rSy`*>r{v4^rSJ3S+3VWKUb)y&R$ex( zk!JAS1N|fUphw?KumJURgKwAX=FL0P)piT#S8nX-xw^W3eeCQfXk+8=kE^HE7X-6p0aeN!11XVe`}Y1P%)H|BS|8ZGjoL^InOSyLz?8rp}&Y^$(1 z0pc%Ms899a-8e~-VN#Wl&^8G@tNLu<`4UylyJN5UR#TmuM1*7C zTu1I5vlp07cDZ2`(y0vG1jIj>DYms>)zBOp<~1~!au4#gZzUH^=W_1cGkHh-7lK?Q@dsd3l#VK zpKm5pUZC*>mYAV(eGvgyPNS$XZxIMYdHHTNNjpa&QGm;5Md375CHA96X zMf(jaOS!&wEqM_Z++e!OUdOu^L6*8i;jJa&2(vS8IXdR0BS{Wddqu@>$ABosC@4hH zjeuY*sn%K5s`sw==16{@Fse5UX!?jBWTY*76kPHV)c)u_7ENvPu(?pizypl_ePPoa zS}7^11=degX3x$hijDC4fL3%)l$H^ z-o*%comC@or(Ww53rCw%S-rE*@U=a1kT0XfxNO<{NQ*KC@qmqqGS+QI(#jB&CE(<( zWvUKX(dF3vYUm3QObB{O2%!CN8JN^ z*yH0Jbn_WV5&x-<>y>1Z?Jxh`4xo zU&&NeD48!2QG}@^tc#-73(YyoKzGmYy!qs^u**R~^yXw0y&yD4r!`5)j!6pS@3N~! zEw<{@{r&tQAXn<`O=!M zewj&6T~@*tOL;iMiq}wqQGTiMvmA+dN=gg-_GAP0qnXU$=mo3&ap zXZ(U2ig~+1rth9lN-Xx~J<_dWgBs}S4n`ke4k8!GyyBjmsPIF{%8TZQWSGv{bLMcd zVUy}h5lu!sPw@>8UsI2ataKQ;2bVjf5nb7Q_UjkZjo(Jx+>+eet7U{#>_Kh6YZ4QI zf0R`HjaMG*V(rqTOgYr4|mpi|RAU)c7!*&=<(v>|A| zVXiA(aa>_y^CY|y67_y3dzHo}eLw=BTAm8-1HR?GbkbKCyxQEabFS6yt>&md=>eT`T}uZ3w~V%YZLT-zPJ|5FR-CaD*A0$gt%)bT&PzGk%* zK%G0-oayN8+ZE=0cWOw*7M!nLrJ&24>#W|+5=Tp%lSq`g4Dqrs3s$8BUesI9`}zi z3hXq*73u4G7BW%HpOZfePz9kK^|#tfC{O4X|1&ucrqQ(2RA3e~L2r{@Ee~#(q_KnVHc#wrlqG z^Fu8Q0my%3H`)XIPid&BYbtG({@wEO9^fO<`A1{p<07Z1{Nj@|I?en3u)}lPc{!j2 zGBPr{Fm3~U@ae!K`kH}}5!c#=wCgN@BVhB>P`*B|=Cc%%8&|JOZJ)72JsjE(nn>$cGoS*nRif+mYtDhbK(BOC`vFlwb?pWG%VC4DePCt~~gK*rS66!3#= z2?@96V1MiZDPQZNhE!tH%V*D@s}pA>PpQmbyIqseYgYeHyfF|rL4gcAgA^kk``>Nt zEjB6A1B~N#iXqsSl2W#_7%GwUXsG6*+DoDZURPjy|UqB?8Vsk3J73T4@o&3D&|-@zCH;jYgN<8C}rJ zqfcDSai@oy(Cx`;sNl-nO<;JCrjLUEa&YdyTwJ(U)Z9e5mS*)fJI zKib~45zFc9GbatV2NVIh2e7$vjRyB`qk`9#;l&`-kjI)gKlVWSp z9GFpxi2#mX0xRAR=eI0#So!&z{W>ZJ9~;cL)S=ZS z_t+os)(k(nE&(VkKDasfIVtt4(&&roE*u! zsld)fE~z^2&Ae(QKt`55_fnuLACW4}EefLO(3%>bUL1$GxAMokl$2gO{SWy1GySnH zjpv5t)QpkokI!%zkbdDct<4#q%hy<`uS7h61m-Icj3;%$KiZW(utUMm> zcE|p1#9`B6(j)!-$F9x6VFxzDozb9BL!4OxYJ3nKHHG_BLwOutW7Xx1Ax^LN4nkAL z94D#-|M>@muzkDOVp?@-l7Pk(9%xqK`1>A2$$SG@*D*C^bT$Bd9WtMeFx1|~7MpJ@ zjVg^RnHNmqwSsGSSDh6uh-m&%1!&tX!3`+5Wq$19D>uDIy_?yV7J9{JD{U#XLe6KP zp1BPKbc{MZ$b8T)-%gQMLo)vryNd}Xc85YIcgD=-1(5xniYczF4|C%_c*6M9lm`AR zoU|o4KR-A8)iXz**RQLm2VMd*26S&dJw53@oaiF~og5j^513d&KYcQ8a?7s&jH2W= z6lwa>=|74q*GtiNu(Q`I)Zy4R%+a4v$?r=coqBZL&)=UMlkbP$^mdx6sPWxK7Vu>g zo_*h6##`9iPj>Z`SYnAm20m1Gb@5&dh^`b>H_01RS@;OrtehUMYa~jXG+?e)s13r2fdG7gYyAi&5|3=+gMn(Cy{lch!TR|G6q>&*+L>deb0qJf9>5^`+kdlz@l*;$?vB~Vc)$0vo_FoN*0c70pAY-xld@pA=A7#~&*S*j0UKk-??^)9MSVTL zgpGsE>1EymK>-2(h_&C9`w@Gg0K^tj3%5K2{6aBLlfzSI?EiLWw-n!R>=Pf-3 z9`=FZVX>d)$%RRPyHirS142cv*Y%>!!`r_JEKK5K24?!KjzI_QXfP zLib1O(0*GOvth?vp0q{u!C)}8@R7%6Dk!)l;hDvmko=2m%ZJ~7XtJ@)x&7h&2eZw+Lw$1(-RxpP^@ zf}s+g+~DBuEny8gQ5SS?mP5AvxS=Pvt0_R=RlQ8`)Jde7k8cxUf8pEpjSvOwKX8(FoPV(V{n!7U+L!vW1Y%<~;lKxC%^U0800~xTXnb_^EgG7rh={wyq$8X--^>w>d)FDoTL!<&K3N36 zs->CccNB_^gX19=mk2-qT_EH^dU_rw`3>|SP%}E%+wbn&099FP8hu)76CgQXQ^k%i z;v57^eLa2B2KZxVUS7NZ(`H}aqO7dvxYy3%U=sM$flA@#t-R&q2S}x2Px9$`oLY^R zKF<^su$Z#wCbi!sv*JQvaz~}m;!JfC`1nOe8j5QX63y%zaeDW7p2P8%?J3uDmd?T& z;aPO-9d=h%DCy{%o15N)VwM4JSC*bCRBJ1stHZ;?z`tc=W=dP1UnRv!eom}#>|F_F z7gPGJ=Qj3al$5s!2^r7f$iD_n2Py@Edvu;W(SG*bYt`~Gyb%^^>x$DRE$_E!Wq0-J zQ>*q`dT2o|1F$7zT1DJ7Af}=MwR@Q!7`uZfd+qvJJ!)cLvAe?%RpfhAg)1(3D3bbg2 zp7Zq{;Ae`8ie#Vr(!c!&hp2RAg$f&S9|8<=%tUnM9dUk!F+zwYpG6h(dp&Oz+A9zG z&G0MdF6+vw4HZf3Z$g|AqzCj2@Z4gQl-$I}f0u~Iu{u%8aQCk4-)$pV4FAm(BP7;< z6|UjkJJX3LGBTD1)Aw<43)r=G1Z`}$=h*GwxRlad*QIe<;*;t^d&^L`>k0&9(JLN* z@KIeIM#{r0wSRtlp*uY#g~zb>8fg@XJ|73i(%@=XrB#Caii6T;RmUuqTvdcxR3zQO z!h~flB$3tXJ%jWBa3qJv2T?s^=?!cmEVVZgjmM_e8A^P6Hy_#;xU#b1Mm-I78|TelPdmFfuC#gO!a*?xsw7zr9^!4r8Tpo1)TQcN6O8Y7nhir z7aZ5OV4LdggI_x_7)z@z4_3NAEkS{wpha z_-SUfz1rrg;{$!%?xy7g77Y`=4(1qpTl=3{B{qE--VGS0h%qMOg&n^zJR0HK+l|A< z%$B3Ia$>Xf|Hc9s`T1P{l}QhEY(GT-OK^ScIb`tBO+9La4~VHZFfZ@tsvybA$Z1Wg zww4wrH#a?)=Gz1LJx@&EBuyk?O}jt%F706QLiTeQn3$*q+@IfASXfX6(tZXPxV^B9 z7m@yoP@OY-ZAk(>R6N}ZI~{P0eD*&=rb4DbcpZ?f=XIz8`jhpsI|8=^o?Tqd52`#pa|Nwqwf zHBlBZ`W8&4#B(ueKqr8OIY8gOwPMx5Y$(!S9bD2kZ{BQ4XSHdR`aD>mtMo@WB5+VW_oT+)O`dn9{qXrBv%{&5%8GOtAl^GzzDuU-wFAW=e( zjDP~^x^_q7)63T5^$ug>I@F+->+0$n1V2nOa2-ZQMo@f_o(eT+xb(y^Dm* z!Zbnv)l`<2c0_({4W@yNxBVm!f%4eWQvV#Ymyf=o)1^U=vcGa$uSg|K^zbW~in$~y z5dq5z(oyW9UVq$WT^?7a?<|ixwSR46mQ6g+|9%5G^P}-8z!4rt`;a|`$w*DDwmI!n zOJEcfly-H11MKQUL(BSu6Fb1V;bas$p7lh3)c=$EWvgmEB|Uvs{kKAyxm=1Ax#4&1^S|GuTzJPiu_dBZKKZlu0)TsChxmS zqWM~xYachK5yPWcOop@GX|f*FU_WZU@r!m^{id5&&;v%K0o0M8Gk_#0rO!FJ+4;K2 zt>4!6FW2j#Wj$NJNz}}#Qw2?1M2etz4Vzzs)4n6)@qwlu` zB?xYPf-{jIh4ozbn}QK}_X(uivwGs$bUM28*5e|KmvU51S?UB1Zx=azTzJ#Hvc0 zLCxp<=qm-?kUG)=$wwyOKJz02p#~u+oP%d8OS(lWX%az8LzQ}BuTdGDalzDrMuvu` zV8M~RL%M8_EHBZ9d4;{N8FYFwvRI zzaY;2)zc&Fd=N5!K@R`@XDXdU@UwPejnN@KdTDnhQcJL+B2LWA8xqU~Cz!xds7ov=eEgc=wz~H$kaTLZ9 z&eqOQ)Y<=`LteR#S6NvaCSg99byvh3S=FBA=s4Tl)HIVkKmXefwN_`ly9AEi7AI1L z=di1#kG(bn$cH>8rp9r-+G$-E%t_*Gq1N2>r$$&IjBLCRR$gL#!4) z63@suUbzS6BOPO78TTSuQi9Fb@IR$vvvKmNSoz25?X=pFJkKg4;^L~L=qFj}3hu+M zoh}piaCs1uo|92+JUNl8R@m;rn3|f3+LYMN=%f~L=X3sb$7br_I8}UWHxiBuzYmG? z`K~W9=L~Fv!|ro)V>BDifDBXY%I_cHXC#mDH`_`V@(}iL*(r`1s=7Ko`^Ug17#O`5 z$7nm72TacZSQ;GszW1BB?q%+iCr>M^Mvq#Py!>b#({R zu{&2CY_#YnPr9jv!F0S`aR9r1XU&NujaP4l48@m@9}Ty09{JHvJvwJ^Z!5b(BV4U} zpfNMlO0qQzJkGO@D2@o9mhwD7r*(+g=$igr#v|Kfw_hoZ`JiTrAh-?lp8DX@O=W2#OBxy)+#7G<$j3tqV(Z|5pX6_rDoHRu0jz{y z$dixPQyJKVw0mS3nV9_9T^wma;$hmel!S{5n+h4Xnf;`Q$JRy4!XamnwMnnM>q-z9 zuXF1D{(bJm`}!6e-C`D6*>Epd)Q{-tPmWA#Z-r=UZIqyqK zC}F$Rs11*Pi-IQDL&d()(ukXyn6Ttz0}h{wPm^?~u7K1cf-m{x|)vWol5pXP0;a zV4*{9Z_3Nd!R0TGi`Fh*sumrbOL%Si>MmY5LX87~fb_zj$ehwDce-;p6Xzkw>yfSP zdkDTvGf0q*+M2vvY%p(4HG)UMv(*am#d(tl&3&0(dWYC_sdi)@kzVv{z=d+COeB#B z&XpEp3whI+oU2^W0kk1-ex=C>i-gNsUMLg@tDc3=HTu z$%%*DuvY7#SGEK5p3}O`gqC2p#V%jpv&W z0bnR5(aNS(tnEs8t#o5y!K659Wp&k|%pp;v6N%q8+Byo1#dPht}SnAR-*F!HM17iQjPko+)H4X=lT>M-U z{dtrzyIQ3&Gc-gmm&*2r3TX&8Th;We$hnu+oieKCRD*o4gzI|qZSgrAl~u*>ZlZ!w z3yY=3y}f-cf4)?Gr^AdP-6TYcLTG%xoMN69=X9wMU%z_e20Vv_yga%*ufL2#}sj`Fu7MxFvcN2>E0^eg&vOj;PyvUP1F-r-RvNfAW{f|zUDm>?N|HTK z?}jm{*w}_De=SI;A*xEPJKp25o^b!Uz@-wO^Yv@S1JizWadE@RvxfCuz=R-NPOH>* z00>CPn965k#~5(>!Dcjnp&L@t*fkGEnjfKUFI@jIOl9Frg z7uBCUdH>^1ee}c5)0sAeTETN~`2%mgTKjCvxC+3RZSi8MY8?IFNXW^_^>7-1+`)3R zf;a$-y&Jo`rKJdvD;kiJMjbYsxdN9CwQZhu#mQDg2VIKUSk;%vj6qP(Y;j;Z3L6ND zgR0)!o*p?Iw#`CKEry3n1f{&;)vG<#3>8o%9BGUud66@I5+yq>O-V6z zckf>lmj0?19v(geZ3r^va6UTL#xoH0@I^y$ zU_Mz;7tP9%FV>18n-lpeL-{KYPBWXTJ;-7LiWvlTjuW~W*MM<^kD_T|fyIWXx6}=f zSS7*`5gA}W>arAE>O~NzFws>2QG;lL5HiosGfM?l4r+M?Yhz_l2P!9m;f~F(5Q3e$;|cz9yL`{SVjG z&8u{!3%%KP?(QeOhSH!Ivwp_1{BJBEDSyv3Flv|r2qU2L<~dKOC?S;&14uvU_OQa0 zUtVvsv)j6K1jXNOtyEA6dcIInDpktt+Da3?p0;sa^v%XgZSD9ZC?cNvN4J2&!UCzD zo!Ye%!@cG&F%07&<*kl9(a{McNd)@9LqXbs3HTpo1LR#lyg%-`Ny(>i(|E9~|H(DV|2 zf5_93=CwYQOTp2#7}}#khu8zW!HDCGsC#CxV>HTn=v`R2A3Lm;aNPyjpNCwioE(>{ z@`A)A0vwh&UeEj9p-<_&e;&;vS&)=tOS-VNcXT?`nhOr)ESCXyN!C4l$e-E_WZ?*JhXCt2xH4zYRdKG zNOsMWgHt@4%}tQ0Xq~p+zKWAr_y7w*ZiXlbMO|U@jODhUdTW|Lc8QEa3wC_1lbKww z-8i6UNRF(C%7b(Sq#=ZowY%0X^&aCR1vo_Pr;TJ7aajJrzJv3)wb0?&?XBwd@mpj>nD|0ac(@n{U|B@}0+czo!27ach2|;#OEdgm?#%sP zNVEU{VJZGUz3?Rh`H*a-j0cvz{rx{*$f)qp(*F6=>=O{lfuH0Z?*UKloMHRt)0ZpY zRXmRX3l7pWj43s!I#yF(F|@LBe?$}`1W40_0Ki5}9w#=C69XU}Fc?tUgEH?Scu&&l zux#7%P_EO?a50^x2v|Ib9F84cPKwxZZyR^;T8)&!;qak6r9MluXG)Dfp8ut&V* z{fIsC@g_YcVFQ(x?f^6)Ir$D1922|C-DiI!2noZQeoi2jn9a>01)w(3$-Zi1=;Gg@=iAj6KxQ)e{Fy57U(vJdX6AY6F7pjZ93?5CITtcH^x1D6Gn zpJ&>Yvf;{E@_#bdy>2Bv^|`@sxA$3lb=r?cB}ZA3@_tt$_c5GkLes-N21|qaqiI_E zP*6O#?mxMB9aM`Yn5lDDagyV)7bqs53c;))(Zd-!*g%C^!-d9oCnJ^SnJFooJBvz( z3-VQOHdBxN;(b$$I%7E#bkx*_kY)qX(b2H?ld7jYKD~tQo38hXx4HtR#ml%C-q zI}jj4Ra0}~jDv%oxsD=Ad!JsB5pfb%0d z1Ujj=m3YE@A$lM`LCr&zw@m_QRgv7fh~dwUTKhc+3tL)VP7-!nb0G#tCa4~^rzR_( zUcP)eK+fi64ofkM0j&slL1QaNK1kq!^a)j7HP3#sqv$&2~h99$o7B z^QwRGowwBXp5fByraZZ5vkB(9L%50UykeM3h+V$(Ak=_?SVgrF>ivwSeMb5(A4E&m za1pF(bX<&IQ^>Dh&>vbr99bfGStU=8_pV>LvM^p)sDGi_VO3x{U=d0yGDOPifh_VdyxR#@p0g`%M(ZAE}=0P!yi}5sSZJS=x{{ua$MzL9X zxLZJ=Ni>K7u)0Iyi~n4*n5>@;+;{m0XDVO(+q;t+H*cVf#l)N^njmEoEiXB95v+|9 z^>*_YOL_Aq?;sH7n0Ke$xkaz~z8hbs;IUv3X>P)3WW(8ZP-}l^J7H#vtDjuRDVzjJ z6=mHbN-8SEm*1nWbOW%OZ`je&fLWVS7FUyC77);M$A=UlAXmDNmX=eRC%qa|bNJQl z%oig;Yy~<8c-YMixE*)MK<{TGDbM=RCzu*gjfnD!huqxPZ{8%H?CS28OPpCkAC2c} zkdTnjnc?e6iID|GAmkvg_sV?;L}MP(0M$Jt(?RDB@w%}&IqB(!;3o#X1I0uAs()~t zpTXVv9(gz!OsZ4u?3WG!D6y_$VyHi95`d=dy+@L;pUAvk(7yu~OnUk>DAAz|Z&x#3 ziROBG84rT3f_*>VlKEu9Cnk2*g9tQJJqZjt2v-6$s5kbj|FqjUq_>=^xsJFylS| zG;~W-|v?GSe~cpO&%jWPB3!|XLQp9(qo|C$b?e6xwHu%S39jTE$P)c=7SR5F%fa(IUO)2jpvzB)7pqvRA%eLZDDfRWL=4m6r-s%$SXVVy5)7{?H ze%L^2^~%w4FCJ+}yr1a%f|i?G=|()Ci!J6|LlW)L=z2pyhY8$F92}h02rbInT?e)q zLhiHe5pMfye&^uvf{^`esR33MkPy#tYTs!<%2URjJ#Y&0IXp|&Zausq8wn21Xv8`c? zHZF4fQGrekQ}FE>$CvmvNTJ(gL{)IZ_LzYo94vsf6jkCEtrbCdjnX(u@;X(CZa??# zoU3a!cn9G;y>xU0_gbCZ{u&nZ!MxC3RJ;*jfciS~y7)vQn0hv!{{BVi&xCcxN!gem zuIXL&E=IQc#=1}wZMkY9%ZVhKq2Zx>pg`m=#`OOZT;qDX?JZ~wj<}xHx*P*o=fXOF zZ@;uV6KYyI`5$}BV5{#uen`?YMk(ZO()TG45G&}x1-(+<4i&0v&h1dP9plx9+`<&L zhWj&3H3u`bH$%a~s^$o^Mg$>wIcEMo7>}2G(*sP9;LAe7CJU%3xWj-;ZQswl0;=~^ zVdB8-PoKU6@G`1cAP$`YAiQDSa@`4EQ=it+I#z5Z?JGzp>HIe2J|;~Yh-bsQ0Ouzv`_VZhdo z2+f}x2Db=$d%`0)ieqWi+GrqKM@x*1?jJA5948KDiB>QL_`QAVF2%nuDxx6d;@a5c z&7CP%D4)_)l=Ad4E+vcp4j7XPwJ=es()5s@Lw)Y@mbA1qVbaLR$YJ}ev}8(3xA1{^ z%<-P84FN|Toz2CS8X8aa8dsSXf8tr6Z+_D)pFiIyer7|9xW^)|pd`A#nF=^!=>4#L z(9&AGR-P$UqNm4j#Q%ptTaK~|UK`wb4h}Yu&tf~~L&$*hjoZKBG}L=ugjP$C_lI+0 z-p?qp?Oe42A{#HT(LtI<6FnUPAz|Q+o1+z`ag|+x6ue>qcRW!=CoVkQHQ&Vy1YZ7f zOC2y7t17Nb(7_sbjDfpVE6^-A998|h{lfbS2nYx}!$hiH{32*#YMKtGS=})`r%hW{ z$x}m{Rq0Lp{dscUy$euhLbw_wXzYm~$UvU*Cpj4zcxr#hAxSU0K)%jRiig9++es7= z9h&pnGy%bG<@?*j5e=Ng^|n4i2V;qC=(oI(p%{Z^)TZ1}Z8A~<35W`KxTWY^N!lU=C*k(H;QP?#)Cgvu&&~SUZlto)9CljH@CrV?m^uIj0 zJRKh&{|T4;38?U$B|rx@Ge5nIIK}iBURxb0Crsj8s1Y=oazkI)8pI@7?wv=QDZ*p` zIDVokSSzJg~d$`!>3!QCu)uS;bGA}@}n837~v{wOlBCyg$=VFKq~yhgAS6@>;9jUn$F(Q z!NvhfgmsrxVuUhLQp&58i(SOAqw#IlLJIOqY8`@_$dV5S0DyKKX9XE2RkGK&K#|$< z?X4$v6WpFi!p6<^m|2Y1iYpxU&UV<|wlXtgJqK;*ovBBj$9UAw;f6Nup^-az&VjG? zPP3+*-~uP#S*}ivDxd>Bl`ul) zS^+oBjC>74F}hT8fnKe1AMH};8;G&ea(roH#Jb#vGOg>)+K}CZZ4~al2vhL9 z!riVU62rjpyUiPYdm24S%C1!+#^DzjNI0}GHm2zhvVvQ)6%YA0C6txJ|6RUZiB(Zn zW|EBtB!eO91>805xVZe5N^8<`i4J>X{CAnBvgjwT(9c%5j#t}vYDv_$we&XW|Cnmf zBjJdx-M@rObioYcN$uk<=Z{1pch--UtR|`%3kZiJI5E3XdUvnQUV6Z_C>~r6%D4xA zNQh^groZ0=%Zfn7`n@;b-l|$z-ObJ(R8>VUMe;ExHB1eiH(nf0G0<2p38of}T<35p zhP;e|HSJF5t=ebW(9bz|*gJprE9>cr&TsB6pD8PARl94XeY)9#KeN-_;Ow<13*W}q`7jRs-gX<#B@a60U>8#tn5URZ1tZQIzb%KzH z@`0na?Cp}*zk9ny{~o>y}R^Un6-G>BQ1)$3npKW7xnxC6#Z%vq9%ozeb; z@WrkYim z%D>O;zuJAwmm`YN94fTdE1p21P=GGqjf6Z>4+z=N6?Su28dfMv1dDI#D`I}|HSB?K z6|`0pH+O)*tdHR_?-*nU`E7=g5i~l|l9EXG*{!lgJqF=wQAzdMJa%7+_)b@zgan zAUOoGY*0V13FZ<+Wa%e_iJ%#>;YXcjcBCpWKyX0RqULDUc(M>cMc2=azNe! zRJfp%ZCA-9>b8Y@xnHTcyf`fRBuB+=ccY3i;vX9-)Acps-x?a0f@R=!UoRRm`E1gl zJFt~54bBR+0^M@6T{CFUn8IHgeB=$#Qr^(>%ayboDF;fPqbiRa5BKS7i z^+-=oOH1(1ovFf`6hwu}@J*M$sdcrh)q4xrL9C=8CjM^+&9bGxHF)s7dR14w3Kj2m zL@Vl}UTxX>$nG=&PI4Ix)%hHF6A#yu^{%M-vOkQSgvf39Bu&*~K@<6SXX;x6%-@00 zs@)kB?~83>K)weP9${tDy?rkL>?!u(&Ru>`c9YVMLyIGpZQ8J51S&(lr2$I}`kG4a zL&&d85BQ=$F4-3Gz7Q5el|lVh=qhXr6Fa4@!>;lMcec#1P+ca zG3J1thWft0mqyuv!QK655%a>YcmAZAW3QWq2hTLD;XEGs#kWrX04ewG3SUT4ybTDP zwNbA)B&wlu?SRVl?`r-ZP0j^`^S>Ui```ORN>OjUz#AV8 zCWM=Kz<{X77bZa^6%C}T%7jY1u*Wl{r%!Ka*T1n@sj>l@>d1a8 z{GsIt8ToA(?&BwY1>84VE1&%K6doj`c6#hAe2It{SQV^nfSo!V;E4IUb1=t;z2(z* zt%Fio4tCSQwNiNO83lRqpj5d)Ct)&?_j|fESiG7R>)0hEB$yEZ;E(S3SKsNe$UKx5 z5JsVkG*gk1YTx+Nyt=Xirtaxqf0`jXf$)1^cg@D%yf;}{nh@e#)icO%AdBYRp9eQB zm5A-@p3oME@d6!0zI@7)dYL0N^xKz$2Tyd9Ia~E-H>BTJIvdiC`6_SFA z(S2$PB!Nl!^t~=%+I9fl;L5O#o1hy~{xvzxXDSLB8s^&>2{n$@{r33ypG=Nnrohs| z0>cn~jHkRjxcfmaGztzZyZP?!1YWt<%Yd{x+S<0)_~S8q9)OrtPgInDoQ%iz^}24I zkdTn4PSsR>W+oZ+uLiIBe1f=82%F$21Shx2GtfhM>eVhR6dOPd zaC|np4mbG*CH9Pt<*~#H3}H+_OL+#35r%;jl=?IIVZiso!TAW%UK4;9Wxe$CXXnw* z5_?cLG~xk0utJdt<2WY{QBl#>_K228QnvojB_XD1c`(~I+2BK_fTs5Oz&f%>4&U~FI+Ss4~uTSKY2d~#}lCiwM zgcJ_-BWWKQab4Upgc?Pu{iYZQW!Wx0=;s-<9)1J1B{%B*ukp`{Vxa0Kt8zRY`PKLo zl?oq&vD$>}$J?o3H+I={S@;1XbDZ5oJ*=nPj50D@c&DNF)%cC}(x4l^l?Ga*!^{7? zh&jq8o2a%N-DB4h7b$`Lq_z~YC{W##dIj{UCxop*9kX?=dqjcjLz7)wTU!KabO$EG zMc7Er#1C&TT&$Y^!{yaj2qZYwNM+LAV*kYHkrdMJlYhIYjwgy%&B*8zb^~C<^Zg*b zxwv@vh+3xm$1RCKPIU#4_5ID4JE##X<|DWn>|0b((yo>1TE3JTG!(|{#mOz4wy}L4 zXM)On@Ql`T<_~NxdB+Bpaf6mymni8f;H)FK`#}h_2Y2xxLWL=l!E1Lm3c@;k-{{H6 zto})h|8Cd^F@{GwmZKT@(F$5x*rd1AH|uAoGI0+Qc#YST)-xd_q;$Tk&5e1k^k?VK znK7|BnyK}Lh3LYdFJDrkh6r0sR2?gXUK$z_gFzRPu0R5iArtFs#I=4NPWbbcS;;tP zb<5VS1lZZfi)_-sP7%B&EcOaCSm<##$YnzxaMIE$99hpWLbf=Il(I4dPr28u)gt*K zT1oqcTOjr(U=kH=06Z12%UbW>$WX`^Q-PZwwk3OK=V4<#_lYubJFY@)4$Y_dg=%2= zb<7`Wq;1@i?h{X(s8j3SrQr3QwO{$Q+0Ek>-o4tlDMa_I?FeItb2l?vQAJ6mL+`E@ zL7ViuZKJunQG&7n-GiSh`OZI-2c$M3PA5NlKorxKwl$F7A5OGMPE2KJZcZOnY0>k9 zlHPT!fwQi9WyLGiA098i3#c5GT$VvADH*6Z#4$s z*F{7XhWG#)0HQ+(q1sm6*xMvotc@R$cCo6n=r2kE)DfU32S}Rd$|=~Cn|!JOAoBFg zLe@e&pF?~2dc&z>OMAq$=*b2k6X2Lz87o$*XK(BKC8?m0x_dHj)oEK%0g(LRQUF^< zUU|8`@-Bt~Vl?#?GCtEi%0|yH+^z>Y2gXw--_c(C-r)OymP4cfJeYuEJOP&+h;Fd( z57RiL%YslM$?IX~R|q z@t5{JD#7he+x>}g2>*N$cN8-m-Iajh4`TaS3pEpJyk>s_(BtoyGbz5~95=P*ez0|Ouctzy%jhnE2&_^8~9Dt)I@U}?mv)v`o%#-+iPiyts z-%>9&n|%v6t&B`Cl_*n`??GCGR&Gq6lJz8Vyi8Jw3kF1F9#$}bF8LpFdY@pvIgWzc zlvMkTmqoi{=ttPTd<|#Ua-7XkIb2E-^zsD#+VRa#VZ4G-_j#77_qpb#jx=qd56b4A~hr}S|VZbKeJ zuufmOwz=Ph!cR9TzJG6F*Q!5R?2CU|{dm2Ond2E1f2^IcEj;czW`koT4(9Iewc*>g zHT86ize}yHA@|8#1Cn{Ob#fy?6#+Rw9NM+=b*|+`GO_+ybc{TPyRkfwllZez5|3IW zZ+3|_=z6Yl{?hEOnD#Y6ug#{@<9+O@o;+L&$^uPS zs5=1>oG9p2U>3m|c$DNwV* zr;J4&tyn5hmQ0jZ5Yf>+X}ws29k?lO@u=YpDAaKn>2Y!vo?7Z~ z{17hX-RWhpsy`DG%hRkof`bTK)rkK?DSsX^_(??Je=Q4jv>(#-A$0QL=5#Rjpq|`_ z1@QrVCGY&F7US=CaHN8|=w4qjV8jP3vaR-`_cpdm=q4 za;jFS&5Q^0s4Pc{bZnN+#`BUgyf!10QITxA!0PcX=JO_Fk}qzP0>E9_EI8o_l2kvj zXYL|}@57WL9L#ya{Ro?IxGrqMQLtA*7ceraatp?tYk{XiEniV3@wKVp?#?1%w(ly0 zd9@F8aOOiO#~}^kL;~WEN1a{<1F|GoJG~l}SfE>1z1uRs&FbtTJW+2T{rdISe<0T{ zo#UL#!SQj+_wW6=YLl%)HJatOJw59of78zvGRqC#~06@bsuq3sX|?v=RTxBW2Ldh(orK=sxN z1aH8yWvAuabx*I!l{SD7m?B$qK!fr`{F%7>SxY9E{?`V}eIT;w&GU`I>vMjA-CBQs z#wbCVN3q>_rC1Ui7eHPe7ybG0H7Z76)=q6`Bs=>qSd;vljMe%}ytPh`0>{RK?=IOd z4bSqZUdC~zpP1dtjnIn2Zce&odXgYy71$;T%@|#AEFWQ0<-m=&DFs7@2>N{Iru?) zaqZ?NyGWdX4oXsA2MDZ*TEh?ers(CL38&KfifEWT2>1hwD^!!7phy6yli&gXLCkVU z3No^hk>Q6Hzn)VX3A>;fhq!on;<&a;Ve5x!BlJ<9F;mS83t4_N!Gew_&oQ1CP>o6x zhW=p~nDWe_S8U%kMiZYau)p-X@}8Eyg~e9K34ezg8-D4|vfv|*2lwy8Bc02qo2MvF zgbUGWrw4egCRygSyf5F>UxUj8=ZOjAr?TYDX`g2A_qliQa&fh(0i;Sp8l?#gvwE3x zN)-VMsfe}sVXeIbFfhAa6?)9F)EJ8q2Ch3IEtMg*Pkq3$FR8?I>C%zY(l5rzmGzsH zln{(^=KGkGGjV-9S8qZRl6p%@%2dJ9GOIcIX!4OoPPm<$v`782_mF3`Yhtpr%zdIV7G+4^^l~hJ(Y?)8PFI23jiVDH*s;z2J;MIaP)_# z?};36DTgU;k-b^iILFSuS~6$|f!f7oWt#Q2rce|OTE@M9{~jo(u9}~hb}3v$s=U@t z$Zgm=rqAji>X^t#T1aTk#dSi|Zm%D5mj>c7iH!GHrha_+0Fa%6!WS@D5lGlVXdeWu zb8&Nbs{sYLz1WutbG&_@xE)_}S{rMx**JH4xie{BtDa{tZ}Dg zmUwm>JphXHk(x2jS?Drgy2nKb{MWy5xO*eEYT0ljh?u@cTD^(jqe9B>+RH zbP5G-1_t%S+oYrjj;FIa8TIw``Ibl-iVDA1#KbB#WDIrJiXSNn_PKkbtMaAitsuys zAgTetf%lk(y;`eDkf;E$WF}(Eqv3~JJ}kTu<<)HiJ6g+M$q$MZ#B*(^XT|k&8a}4Sf8<27a@({tsK+` z^Ov)%YuCiDzJb->X>#4v{K57B{c}G!sKFTU%r602siC2PJrum(UO0rLeo1XXu=TH{ zwem}R1UWM`w^41zr@1b-(Ino0q$G$qSB<$(g1#0_Mob!Ta^Yl4_4x~_THD<%&h7c< zh2oS}OpJ`P2#e7SkAOL=ZLb6b2d{^aO-W$>YAH9hP+5ahp(kYU6KR2>CfX@8%!ieHIuS5Xtu0&ySq%rTn_C$3D$4LXozKvF#!@+n zYSw86KFlBSqZF%e&HPqJn*rfes}SpbI9Ou7zb8<>97x4|7C^29fU*BYYzvX{iWW`K z8G(pGZImyo4JZ#lTRNM|dy&8=w6XCq&F6%duV44)8Yh()T6QH0CsZ~CQVi*sRr}|f zq3>*^`GJ^njg$Dv?h;RYLT&&uN7bg@ZC$oLdyDD^z)qkS$cMdHCH<|akqv5Yd3kkV zVa5id`)bg6zAHaRC5N9+8PpoN+P{l85UzD_S5M?QRh;?<26T3H6)3i7K;x7i0jhjn z{R1);m2zL-E1NJO`is*N($2U&s*=!6@vHcdl{WDx1mGj&Sl?LtdJNZlF!$K|Cl%J8 z47Q|4YV+T@^YXBr5k17UP?v)JSRu!kF0RJbq1Jv>**EX8I;ToV>RVfrNPjY1?&k9}y&a6Q5G$Z*uiTHwU-)~P zlurEKL%@WDzCMGM=uMztulil(EU2{|C{dtqJ=>jU+Kd44M#4Hdxk&wlLXX1MQ(oC5 z5q|V(4<3zcd)T!x3WO@hp$U4%y8cL4T)btw@zl-bJQZhQYpU$LtPYr(*_?9Ef%iWf zJJUzrb07!l>b#-caUO=L=Nbm6CcQY5XC-N=6~J-Mg*sO3cvt8F3y!>n$!DxSGx>qt zn=G~;VUOvIhOhbxKXbu(uL{46Q+o;f zo6Dj_QOckFUEpc$^}lox=k3rVJaC@F*Z-HQ>hBBuSEbkg&JQ_Ug$sHBCFcG6_Xh_C zh6V>!)ks@~SUK|&VWjUDXo|zbZ&6T$fzjiXcW{uHqJMp1Wh^T`KP@E%nX?p7z-nk{ z2$S3oE(=wAVbHS(pZ6bos^(n$ycp zqD)Le2*mcq(yW{ua*C*8KzT}yN;UM>tL*lF1|?kAF@c)Z@%$ zRy=RIy1|$`f^nUXc^|#m*qjR%Z?Xp)b~-vwxwLdESP>IVVlkt5=`z*E>K($l46MFdTJZ=9k|>XvXD^x=`R-gW40B^JB)PT0cWz*klFN(V2G zW9&pY;+r%NJDO5!{|*|rmBe@|x!EWEYM9KV;O&f@_-H;9kSUm2vV`>h+i zK$v_jLs1NmK4g|dd)5WCk17ER=P2+0T~;~Hw!h1256k)xmNgEaiptDd&GLwJxjX)M z@mz3B;e*0byXFvi8i3v{CO6GTAAVSE7wkxrmEHYxBUYmzJ`931vygW4O#xAR0bAgB zu`F#MfV3Wbe|JiG()D=Iz zS&I}qMV8{3n=X5tNOHT>CArsrwMuaO;3Mz@pxaB1s{$3Dk(Op=TQFfPDUbpcs#USz z_NQq=z+v>$rvusJ)DOP=OrQ1SJCR?2nlXu%M)sn>X{@lGt*W#^hyG#wRh4jpjrpel}nnX}P%ewmEGRx%XSm@VKHP zBdrX5G=D4Z^<_GdvQaYE2Sm>-s2ih>Cx4F!K+KJ+t8PVEnY65A^r4~CRh$XSR$Ll5 zSdU+~&wxj1hF#QsBLt$uTEj$Ouu6$?cvrkodU^z=9700y6KWhH{Cm4rM?rS_DVUNr zxInK`7LxZN*mrB8eHM|&#$L0>GGdhawZ5k5UpdyXm8j-s1Jr_oVlmeJit#)JL#UWM zF&Pn%0vTAzbxZo|AzYjmzqc2xzZ(?B(b<#zvd+1}l%r-2fOkAX4Ug1T*T5jajhb{T z(pTwJ!9aWFm7N`hO%o@o0m0^=I9Sf@BWfM~Q7k5R4O* zl8&piILz!G%+sy&Q@4Jzp~Q7^SSAYHD$?0hPg&WdG(-4V@X_L8pWv%MXVa~xBQVXh zLk;)vz!{RwK<{txbAg}E3%}HNs(D~Q<|CKT>?bEe!IY;; zU-@~=Awzp=dlr!)h2#$qkFixNqz>}E0mieyaux5)2`6BPW$W_>^QEI|&$R+BDOOEO z8~}#9j6}EnSx|_gr>7yr9`OmL>wTpop`_GqJWFEM`W;^0)89{&^zQLH#jX!!Y~mNQ zeIxpvx)E$_7QmJJ_xS#}lC9pKue1+1Qd4vDP(DUlToFE#&Q=-^A|ir#P7_1~>?-=t zzP;m}>rFpJe|1G|tjF@K?)IR>^_^#z79}NZI1@Vt2f;wZPJ4#@^JG#a?B^cer#8h?FH~@;iph%4XI*$;b%9wwn&&%+6A(k$K01Q9q6tlSxx(=8z3?kV}|gn#aYX(Mb?6 zbcpIQ0Qw9@KR2aG42eCY?N%&);@~4yptv^ue5v1xJvDH>p5~6+(WcL7B+2Y`Z3X8l zOqAY!_^}6@hW+mej?89hMCCifd_;B9aFyAvJy*5ydN0f`h51lOG$Dame^yN8S6G(R zUymC2CP={eE9(LyxjiBOU#r40!oQ_2a{?^K1rQfpSqa$)bzoEjdJ>Dw+3vDlzkVI$ z$=9Peq{Q>S*1PEfv%pxQ%Uc-~CYm6~y|x$Tu)Xsw+c+EL0wjMwXAV|rJFQ+o{2jb2`ki6%2Jh%23CmbJRiRU9w6 zKC=|zG??#UVxXjQuVu8{GPj@rtOYl(iZda&rB^UHDwz7m&ra|QbW4x)dwisZXdS z`#$$Mdtdtsg8ASu-sKR?{*sk-EnZdo#eyB>m)+U&KlWpG;N4t%u(oL>f^rR}^?fw0%HW|3Lb-e4|5CU#Q?>`CBnJFf1mfMqiy zQN%y@A9QD=VH73 zyj9;|mk9`H!e}?Oes**ApsqC%W@*=Mz0R0AL_|W8mX&3oJ9qQWGN@2aWgrP+W5NY% z7bFG~5%8>!+qddWQx-S+0xlPeEehW##Y7b>AdjG&E2dJgI*f)GS_(;x8z(@%M4=Q5>NZs-Q2` zsDCt8+7%&{#WxU9rXi)sAny}|=jX@TE}h$FO@g#KdnPHFoT97Q@QjPQBu#Z@!LGm6`qcMsBPOYm?*!g=339R-A-FJG^f z78XJn1m0C=YIaKy5wdZ|1W<4;t(AL%K*W#C0T}n=MTT3x5i3hbMh^eG12MbE7_Ecr zE|`P#=6eRRQwQ@aW-gT`Abli?W3MM6=h;D0)l9jpkM@9xnCEqV-({WICK;{oT#tOk z%_?jub>-7zJ?c_o9rC%>3SGM1Zuz;M2%0KcaOn5?EJuDvlpsj&M7CNug4PovU$5V_ zy(_S2NMQe?-$*Mn*!Mx`RmcFO(5(DQgd}RaeJHg)YG!M99VRbRN-#lRqPp1)DK0Q! z1m`#pv*Cw8rd2RrI-GtABoYw8k&u(K^j?=IymRMu%1b+-7DF1B1j;kmxp^68bez4M zrKoXih{goI2-rco%wV(yN>Q%_2}E0<2lXMvCv6~mxShR`A7_qjgCO7913hma$8T0? zOUX*@S?SOTY>aQ1iyhN%P*cQiw+Tr5OFIIArbW-t#IC-VIZMRUb&GhzV+H+`jLd5= zS8L|GUE5%S#kg($>y|W|jGo0s9;m`qrTLxTO?di=m>93ZQkK;t&diixR&hJ+z2sv7 zdDKK()kpis#V`E~GUO~k_VPfx+WF|W*pNy8yTN3qgzlRO*+c%2D6x!;`63||E(6*k z>6Dk6Fa0nD8i!19E=Fke6cQ<3xIhJQ&jqkTb&_fd3J4%%9hjMCz)V#eNh-|tMS^W9 z2no|1>R@Wd$;la)&F^x2&`c*+T^&armm0gIl@R(W6v)ZEt_R0HJy;fm-nxvC?+L3m z_K0$Zm3Nc$)6UevvB<$`(-J*_11g?Kq$`|V`;oHyh3vKAt=(C%M@PrW-aa}6%*`#o ze3uA=TD!JiN_JUocAIR`2x-^QNtKWIHS@*iGNL2YruLXE@4qEm>?D()PW7hZR=I{x zrN)X%%}hA4p|Scu#*{y)7sY-V9*dFQOi?k=C_)0G6v`7GCol&GIU^bGnY(j8@IvA9 zlL0$`ZB6QdBdJlC3z^qT}Lave5o6A zkTo_jfvd>K$voHGQ-b}IV9Zq=FIe#Xfg&|8OK51~^czSQB-m(90XNsPgOq@eqFPJU znvQhTVtucMmIq6af%ir0jldC48{)twN@bXc=_>8Pv2Pln~}_CMNaD;9)=$eZS`p;OaPZpA=^m zs3G%AQIYfUW300%?Uz5UmFhWq`Km?sgQt19@aR%O*seFEc~MYkg8qPz`oCWOJ|z?+ z(1&9+{v4q5ORg3?KhkYZ`q8Oy)gd*Mnusf3bjPfTO_R z|D~8%bab=?&ZfxT#`zkbY|XKQdZBvp^H;9G+2oScpy{jrKvwF~kU}ENZ*D*6p52hO za*+G-tu00&=Uh0hIuB(-0dtyx*>>Ctp7F<$X z9Ewk0vNg}kDkCY`3zfC-9b*lit;K~Ar?q#QhvH>7aL!;X`&jJNtM_m&#qoj597p+1 z^CfXMQOP|Caq;efllQ7mu&+r^)6w}rOk5s@|MG5xOqH?m9;JY~s&H%Wg*p##*X$&? zb&JMB2<-2&VV27tEES;fex4&$k7}d>&|~yZ@wq7S#Ii0?!wkY!TpGVN`pIH0mw>oj zW~zn3bJMln?`g!Gwr0zL*cTS&cOIv%_8A+$^-2(4U~q17vhdoQvECo=W@V(ni&!;X znswo0P^11W3q*8vQ|Dk<@jLT)lAp_x%pZxk7JV<>pCULnPG__R~l-jgLQjdLtM_ z6GTKSV4@!ydYghG4ZfJpYqgQ#F#$)XatOI#MzszO3g4Ulj1B-9L2A3)R7w z)6|Rww@uW`IxMElpMN-?7Pc>WH`E|)t36vERZz4WU;`J1>N&oDEF&1-K68M?FEKF& zAaD;44}&rRu8{}*?q|mG?S(cf3W}BWlbwWGy&0j?9_iCW)?;162wp)!uD*z@noB}L z)vkwEo8z?f^c)wtX{i4eeTIQU%;~UoW_fv^)A~bL*vS|62=h_FkUVi&we ztU9jsdwi=$&r0u2!-h1nc;SRME%nek8?Fs8At5;_Dcg$lvuDp1dEKo)W`LWA$CZbV z9sw)o>zBVG#$HI+hn7V!$o(ue^9*CuJciv3<`_P0^u%nB33@-yQZm;_eqQ40|DvPAthj`eY(xk;Ru-1-v89=b$|W|9 z@iX(Dm(Kq#>BREC_^hvC5z;`j0}Dxw439<{>@|R&yZb;ue$a%%0Oi^l3yFsjv6UP;+0|T zzo~FFftLPcdt|Yb0%T>ni_*JD#jcFjF+QiGVxT{x!&@sVF5}`dj+mW&z)qi6t00gQ z1aLxvf_m=9{uCr^T1q%L2YlThfzL935hj^BGdjSDQ4oP3kd=te=&iXynV2$*5$C-f zDvtHBj4t?MU9tSt@HN!c)ib*jPZ`Wci-V%r;zUFs8E7_vB#z&GcJsqWkaC9o60UZp z5^#NPwhCVi)HgJo3LU+@g)e-=In!k#@`39WA|oDC>9F=LW;8T-v|+BkM>j(*R-^KW z#H;EC>E2;XeyuRL1GVTjfk`BrV#zB6HexI|I6k!6axYd|zXtpyDoarZbMuTNwjgrk zDDF#EHx9qU zS}_2Q-XRQ2on~W2E&GUe<9KqPZxMon zM9{Ad&B~V3G6*;uKeWj&J)Gd3T3uPOUm3K6fRNyLAZ4U#^V@E|8REzT$v|xFG=q)J zb}=}su?m>~I$%`H2!EjE+FkbnXsue4lSHjy5ovzHrnm3jh0&-v_5l1S28->9?DX86 z?wdE8O;McI%%{d5Jp(9(5w%vLM_0SzMbwMzbK7k<`X$7~T*mg+Ii`wS97=G-oP!Cz zMKx5rI3NddBf@fraupdY;sq6J&Q7=HnnXI|OLUQ?5Vj;G0A&0ZS4n`@v0Bd<5<(c# zn@{a6DlXpqHA=bK{6(8nXY{k%%&XM< z>!j!Xxg;dcsJLjETSi9_7DfsZ(oi0CDmbWSxXLQn{FD<(3!8VapDcDN(t~M^2lg3a z*w`pqYL}=5MV=b;)WO`r6YWP<>E7PndH(*N=v&n}xH)ZXE&1Hgq3x!o#&C=)8GzlI z$h+lk`6)kg9@grjc|X_FyY|bV~FI_c?Q<01BVBnE-tJq zzLy3EdP7QP$KwMKLffy7m#f5$qBFI!(^JXd(cea+&3h^aBCT}6;|yFXqTIu}zdyXf zqGiU!_*hMK-@@bX!jh8pp4{UeT`@9l=0eF z*lyliHImtvF;;38lf>YwK2^i6ec28*P$b_+&$z@X`cXb8e*+Wo+iPsGl;W{-=-QHv zwsy&8V@Hf|SA2=_fTZj+yXhcItz92tr*HQdENv|@RW?iKFI|PT$XHvn4FZX<{iT@{ zFJNy1CS{IMO9ApidKd#O?R=*ha(>Q5!YW><4+^2yg%3hZurTfwtGOEp)e8d2izcKnUbX>EOXXD4(%ROcyj zAp7`+5MQ1~`79#+Yitx@gKt;tqq#Pn-)Y~$SMlTJYk_lDT}i*C6vHf`-BVN`g{3ef{$%CrY}IE=nS8dCUR!daZt z{-kDRdd)C)aeDpph<-O}nYDQN^>yV@1bD29bYiyL1@K~PEoat7ii9fn0~?`9^!xg? zADumv2UN3H=4Z7>M@TxU2+8w>dlB4QR#rfoy?WyY2>Rl|;-ndZ>j!(B7UzmgcC)UT z0VAczD&-}}inh15B$%hlJUqZP7d-8(&+;F!n~zsH&CYxKQ}BzsS|az>e;hbUFsHt1 zDg5l~-WkFmH#O--+u*o|Dd+E90;h=MeVef&8&zA|oleb4jw%O(uE}aML|OddTsyc1 zT)KQ2p@vA)u9#l>9Bc{}M333n*d8o*kt~>xROmNP-{&;MEH9xL3?-6pm0;>VTu9)? z?Bv?t0Wx*s!t!(u9Q=A-!E|kt=W<-TzC)%YW}X)rqxEvelxyUaKt}8r1tNNadeY>}|C!lr?_U zN??#Xw{T)Snu^K_b1)fvmunPVkiwgz;jS2WX4Ux9`&KFTk$r=GWT$C%PeS7Ws#Ki} zD=0YpWK|o%ritvFuyNUE1+x+Sh-9K#(gH~_F;yqkFlOG#AE(AkvQwGbCz$H^L&2+m zZOU^+1$3ntc2P27-xB414gw~-jp*siM@uNQ@-(VTkn|$1jxU^~s-2|^OYDN}_NiW{ zh1;%e%4COs}V6sDU@MsH@n3+*zmYa=% zr~1CQlSf$;Ai)y{V_#qTIYYT|1<$42VG3P+tRkIIIyI8{L&>Pe^~vwc6VCgr1En37 z-fxK6Nv&3Q26=Poc0FR<>*~7ohBpzb_FnkvN(H|%G%HCmzrT9;FBYHzHcdn2PTQyD zX5_lMDHh{`hbH6qSch`^PS-{>iI{kHTxOs!c}Zw%pT(Pl;DnNL{&ICAJ8a!9lgU8pS|QpI%Zsolyij z7aB3{@WTOSR5Rl8<;E9RBcxPOL!C22Kmu^L^K7c|l@|(SM zh$<_v3M6xPImR@}Vca*n@7~hgJ~bDS)Jq)f@9i}ou@ah6tRq5iYc4PJg7ws=gM#NTq zONUg18;v%VVAW2?m#~iLirgdp@i+lPD&Z=YkxvzpKhLLKC1J`=y&P1RNqxt1{_54$A5lBKt&`m=)5-9;f}5I&qHMUSxFK8TKS%7Hb=dt0eA9aFINR zGi?UJ+kk(8?da;)@V(=espzS4JPW&hZlUUpEY7?5je~WTr!na4d>J_CPk*{guJ-X0c7W?UPj1P#W?uT|=gtY6RNSXd`E|`t zS}*<0&t47)W&DT$%z>PY3=7@xV0+Fwv0Rg|bTHx6wTL)tzzYC4#c^x)G$&_aX(=i; zR?oy_EB&hVRQwFRcFpJre{E(B6cy+_4sEIPf4RA(%r-EcRZVLgp~rdr^DmPdOUu}_ z*zPpjt>9f(tPI~-7M>}RbJ3`=c0|>L{Jeh}C*W>sq?g6>E%lla`k9lL=&ND{UX!5E z&}Bd^;7F>Zo`$PPl~4V-P5HspJ;=6b?%8El^=mou+1e^dO8QW)D3&fx^Wm4=wrpkP zRJ70#D-RDazFAHbZiQ%2eN%mAjZS#<^s==E+G*B(4s%$&(ncj*5iMa_Q1B>t0#o5` z0y*#qomVD~@-?m>GvrY1QDfSy?}qGu=&XL_CzhbM>N>kkOq`mVJ2(C6V5F!CdR)|} zr!UXp)HSvB_I9=%pW^pbRx4K|0nIMC+U@jcmp>z{*hsjpm8r{Sih%>QU^g||TJ!AC zb2ZuZ(C0DV?JcLh2irU$!3tW!=+f%P$;rQ^rQ7>zqN6b$-P0(PzFFMa{CgWjo7tx)nK&}PHCvBj>MR}&Q#cI2rl z!JDTggh-PNfzLy?X345q36BM%QsG+vN^VEYZp6A|OmJ-|`&8ZVu=KxA-}`|6;Vo7- zH=$4mBEngblO`>p3O}$0N~(!}`uurwZcN5dPE4wQA|vZrv856A@U14)I&jL-7U5ne zVzQW}nudgr8c8CNlET6+=en)q^r#y*z}zDrk@H$#M5Jr1tSj^Y=~nD@BY$9E;CQW% zk~JDXkiunet%u!|;k4^=M#r*n;u*{1yww5qPhPkQg5_#%9_y`PDY(}nH7lf9SzjP~ zQnEKTc)|Vx4ERY22&#-)Ah0@pAd5Ss%a=qB_Nm!5C21!zsc^TxvVDAICf;zt<;iM1 z`o=}?D_3AGs-^$WHQ~A24RGvc3<=py_7p4^XS%R!@qd3abMq38@I$tjk1u1F0Zw2C zXidf3%Xl7c0{FtPvwJ;T$vikR)N-?%9s910_fp`unhn5^+H^>!w~rT2@jAnVy{`cQ z>IK$2zr9qUiY)v}i8XX+c3rXp1R^QM7!cXvF(7RQBt))Tyss9r!{#7U=EnOTG zRPcX(!Sk0dfea6&*#G!M>ca`RSiG-g7w}ZyV&1%hL#l zdr1|kVgBbAEpqn zimfd$a(w*!=oaC>+hnp!x5Tk)pDr(pTc9$m%jE-a89(OdgNuT5MwhAUzX<%e8+dqr zU#|uD`};s2Dm`B-b`D34F3A&Q8gM(jy`$h7U+Mn%_jyM{$<9AeNxc{rRQq7N@ zZISLXYvs4Z0vMaiUj~rV)uJ1Bkx&N166e22-hIc#VLjaC7=Tyo{f6a0fn8bfgMI!F zEUcQ5iLT;MLxA%btX6Cs9-7L}0mTI9~aXo>Myjb>$)(>6FNT*Spq;CJKKu5ktTi}4}{ zRA}Z@#*_wl^T4z#mGfpuGpC;^?2halyK<+HI+{!gfXuPcH)NJ@D?` zyh8trRq43BkBY(>=9-nl?wEu6mJpE60*o{;*c&X$XFIBCcoE z;|92wE;TeXsUu!1Wh3m4@I|gs2y5iti~0ow$(`NZg>d0}_v-rliy$qmnPbGAT0Sx zE+m;t^Ry*6fkZ;#775Az_>gMv)hlYN1G%O#*lZrRsbV{|7sxyTwh*{&#;QA;;{<%UT3)r=FMDs9f{IEoDgR6?1T$h&HF_*=Fb@TV6{EfZD`=Un` zyS`t)h90Jf1+-#(!kEMQm(*x!0&V94I1Po(#=n_)?P?!<0VV6kdt>} z7neg9g9IsFg5BjC!L~Gfd2AL@8 zUG);PBBPeKS5ke5m{C2+&awgKv zHk#Bp6P%r4>|JWw$CHZy1y@eF4W)=XjMLZ>Jj{)a8ND07Sk$OUVI|8>Na83)u*yD) zcG#GdIMevb^zfl&`wx*yMDt((xlnQ^yH?==;KQpJFxw96uOXT(IesOy8>&@pZ?@G- zXVTK6+XSXMu{E6xyGZC8x!Bmu8*`!@P!o!HB&=Gl>qmB19E8mdG7*FX1Xe5P1+brq ztF?T}s#a_DdwZ!%KmEBkVg5vg=wua#8|m%PJ*nK|-65N$P9Z0Bef{ursE%;n2V4&L z=KB0DugN>m0(q>)I+*=~rA%%1XF~op$W3$OR+ER-)!X}QQ7TN@ z8$bT|NHQ_7M)sMj>uA;Z0JxKb7W=f&K<0=a%z6$XNOQqPT062P4#gnH_B!#+s4BZf zeo%@SbH1YB?$5wu4?31C?V)@WYEaw1_{oR!Fw`+gT5HVCZps)_-&z~t+9@C^ zeL!OcnY@K|hgtdg8CCwzX(2oY(qOt^N3chYxM1q77%}$q4u4K5SG~XZgsDx^Z{8)| zTTR}CF##WXcYpX%|^s=^%aKD%Y>5?G|)r^9Pk zbVxCe<0t8icUixYdWl>OdfwO;1=idl2L}Z)k?Q?vcX}k^b?VOPU(R$?kKJeM!8Seh zGmHeYDPBkOnhw^O2^bnK6+czxbeLc6Da_N?&jqD#vlw+`MtnTbuEt<81En6+f49#2 z2Yj(#Wv#F`c@|_9v^OS{6B-*ES`n^7Z@06fz)w-%*eL9Bx;Bso#g+x4z$HIka`0Q_ z2|VE6mWQey(Xz409&Yx(0<&KB5dKwf@Tmb^XZqP_p3GR1s3+K+i$E;YT&CD-N5^Nb zRrSP9Xgy-Vj%ZF7Nc^T4a{%B4aJT}EkwTa~AO|u9-fZ6c&(?R5oQi1Z&z5hWzsV+Cvj4w z*d2ICT+YG~3N8UJIPSK*@82NHMU((MbTHQ*Reb-&CvXrz*EX*zW}GB;;*)ayfJwzd zTf~{k!RBLtZm^eV)9OuNPe3zxO@j?9$dx>AoQ{#bynLP9=4n96rCn;Vo}*AIgVS@bVOMSX|V3D zX>#_f7C=s-a=r?yn-kEF!}^~}^yd$%402REoM|2LgRH7G20K@u+7QzA;HJ*i``Lvz z@Kgf5)CHz;*slHK`?)#mmEIRoJZ8O0)g{C*tumd=DG#>3oZ`qZALyc9LyR{S`_?mFlIv~@y~tenI;H}eW|VOflME|_OKjWL&CCEWTN;7Mq0|D z0J+r^fSO`Y)BOyyo^i*v_5EVfzOb?aojFe(QBPt<4ZU5TFt8pl$ONCA>(@?en?AfV zZ|U-d%P8VJ|826`xuL0HY$Mue;;H)7Z}gcD`zXgJ`v32e$u!* z#cOs1!!~%J)<$b+9zF!4msf7NEq#3%VIB=&1jh{fbh_<|yW3Hz@c=*Qfy(^+j#fi< zpzs1;h{$J`@bX?)w0Cu}n~BK9^Ovkf44A}0M$*pOdvx_NDfPWN@w-cEwTf>1~2SD71{0cN?ElP+V5FwD{oc zCkt>P1x-8286z$@*xQ$W|Gv4i1LCO>{Jspvvv3{l8zSJn{`z%fSeP=K%{DGRRo+zO zBMrWjCMp^+zY5?O#GYedU>K2mtZF)xdu-m{T+9z8w|b#%W?5Mol>TXH`j)S{Eo#r$ zSTip2Ue5Q(Yy~$ruWMBFeqx`zq}p{77y@W%JYpYjuQvy+{s5*RT#ukXOr`hxdP}A2 z))CeE$Z!~@GgB1+(R*ik@B|)lnP^@`?62ZbQ3~;G`fId>uJmO)90kK>2|lWNi{6!$ z=tran4LEK6H#m$nd@3GfQoM#15+1l(=(ABq2j79YV`qchUm{Zgo8g)xv=peaVWtXY z0yCAL3ah%^A!hr~W-;B5lBrw{Y?(v*tgsw-eAhaRgdw@tr%%9QfS{@IuBj;~EMeFV`tr_+O0a(W=fj^}rLeKVyX5Es zBp4rW|ANBOV58rytxQntQwKo6lZVm-@27+W@U+hWemDyYE8zM(92_v}6t+9siJ+-b zzg&6W=8zr6k92Kx+h(o^v?&OaU3#-Q9)h za7PC#IYr!p0^#l3kTH*ixG)h$SR|r3X>EO5vY6n`GS%&!;8z!LHoGK-b@cVkbaj~@ zHTjZs!A*UCcI~`>Y)g0dLtq{rZbv6f*(6`bsXL~7RXzyR$``7@%-W<%(ls|v zx@QPJlveg^z`_G%zlF7RFd#WDbR-d*o3Ehm#>HLw_N^JrY~d^1t0b_T=R)suN$|S5 zxj~2-7LSR9ATMto__#FGzX%DJS^olTgA{0jbMs1@3vD>P@TY6<@$k?C$;`q0czQYl z>XIj=y()nphsT!u0Jyt)RS6Ir)#evsSo8?H3JQsUhN=S=w8qBkOHGk`z-|oFaQ*{J zVL!$3P0M+56_s*O7&{qW{ojF7Q2I3s110WLX{kSEm*v41vBYyYBTxQAqs0PBGC*eg z3p_)x$=SBKIbBz02#UiWZcFXiMC~0pv$Jpi7R6YM^I0uzJcf$^tH%Ry z4-S*@Zw_(%>v3ozY}$)Vl2A7JpVhc@1xt@k z?Tn#1Wo1`gA$;}q>*X}52zuH0oGJ!5ucAa8CVz^0HOj?qt|~AvWaK3{WP&Qr%d2T< z%00xrC!o(T6x@C5hxs^|R?!P>G|+5eN-#!+n|cMzo&1uLB=*hcv@6ADuNf{83RRp%-39IppxK_EE`Iu4-9OOB`=}*s ztnue$P2EzQ7^QF_8$CToNG{-|8zSGe4WOKYtcSrL@^c#HEH&g?VdxJhRd1eV2B%%S zc8#`jYsWR(y$1qkko|dXkkX8cPua{ikge`89{ETyUADuu$0PRBrGNy%733hwV{m70 z%iqUG1^e-y=z%o(@YuzEbSO8bOzDBP*5PJs zTr9*lm0FygqO?~YDrCNGFzia$MWVt>rNWq%PZNBtzvkDRBCtIUjtuB8%Xr>7vSOVc z+naiRCb=G>Rr9sN@^!@mGL?(c%gddNUJpYe51g1ODv-_xiG}Ov#g6Q>2OmCsP%kw* zu{&|Lli$yq0M~DLRf<|Go z_yJUNZZu>=`Mg{Ycknma6@GjH#&=hdAsm=ke5+z7d*(OjZrEKO%=niD?9b9kb;X|8 zJPEln*Ep1=+*Ut)04E<}{tPHGFbDku@j#w(C8W2@c6M^0W;TQkC##UWd2Ox@ zSBY@1lfS;_;jXEv`Irc@HMMrzR(AwAbw_uAo!BhBqL9WGD-aS*L+>ks>u9PgDsKWr$`m4~*sw-mps5uaZEf9Rco{%JqYasx-4O8vdJwR`)2NX> z(?P_Imek(in}!~?w6ql9s4%zgp`m>U$E?-)h%qst5vp*o=*zMLjv}0p`I@zRqo4GD zViIUIM=Q)skNC#vJ&XUv0~jDwGwn^@@JB3 z*JVN-k|+1FR~ior=AdO-Tv%u2oqx)%U1~a}@(YiV9f`2ttb0mGzBXE-NZfMbl%Q1& z;Y^|Z$Lo+{D@AApeHEO-*uKI^iq0nj4y7A-_;Sow@B`pLVpPd_P4BB^CiaWu$|V&D zVsJqL-Cun~r=&_dp@w4A;V)OxA6sQpkXZT7BEfKZ`sZpg`}h&B@i-p~i|y2@8$Oj# zPDr%mx2q&G)ak$tns5gDRJrX8r|dd zv9fH{Y_b)fG}S}gjM6a&fLpGek;rY@LZ*iid?!V z#c**Y9?;83tayRrrO=gE8a~8A?z4`g@v)CxUJ654o1eco04k!0@`MC!?J{?hsgMgG z8h7YCTb0LjHG+0oj!so!iSD}7AzJ^#-T=w<4zgA7C~@aCNu6geG+JNPW>U_HtT|!#x^6L?_+g&Ign=i3G*HNmL0ut4sUe+ihPFh*4?@J7M z;($3RR~md-s;RLrE#!Yz)Ug3ZgHrTnNOG9I}9@;5puxP;15yOyAd&#&<*wNXE z3FRHFujeKYRQyp4(0l2oAH4ehzFms82l9$v;E^nrwwS#!0ybc7N{X(jDRU)MQ*#?L zdFf=-fWGH`24bN2Tj0ei@4nNhwNgCq5Ad**>FMcdgNBdHnND=-9kd;)#!YhY0QX|5 zklHO$QbxmLZo9(ngN_*9Y?aL3bUWmaAAE=0s<~Pd84f6LqP=tHj(CiThH{NY>klRd zxp;;BGl<0^2nv$*KMd`^wg4r6>_xjCMLD@B>`or7rKX0!0(n3St>^uE4GWmu%f?>E z`MoOF!^=Pr0isYUJ8B?9k68W7moLMG+;jmC5XD9ifWxL)LCZUU9vE#!q(f&NCWly9 zUJfmDX}-ScFy#)?3Co>jf^sS>K`^2-A)DyyS3l}AVe-Z?1=JxhDtjeu?G#Tl2q-Co zA|nsdUqqELdP^%P1edExUhIPb25c5C&d#uZckDO#9__BGWMYFF9lvVCdmFl_#Y)rI8 zFf$ke<0PU@g~ej1)T{%HJP9F}T9?;M)+yq9rm-YLg!iq}y+W;}TF8 zumiq_tMzJ5Zm!gAcB{iy{XbPr;d{^y%fK|yBK~ifV^>#g;CdZ+eXiz$Poi&ri;60y z0TdgA#O#{MsluPo$DV%Zh%+!_f8e1{+9U@VH2dP&tJgm<#(GQxW%mvv7ZX6%`ZHCaYCzrMb5$QoPn65ESS`${1rITuk14U$^nyG*pO zUfS05^fYiD;Ge1H?&gUJO-aFc=i96bO2PCH=XHf_+j&dNrbfY{3mZSiwR*}9b-sF zDXFmI4}Bod8u7w>VZFr-4gCA<`4*5{ygGD7?_>hEwB>eq?&~~v5LaYQUe4ClqGe#n zW+rBr7t9SZ?22z=B7E>o8#+no(klr-cl-Uxhhjp*C5%GwVY2E95(()T+1tU*%}VBT z`X)w!9F1NdekM?8k&~16smPN>oT%0!Ue8Oa&%<%})2GtQWpu=G_Vjr#)fI;uRD67T zuUa3bVQUzxL6#`SKy&=Q<`rj662Er6>a8X@N&$YpRttv(MCpC)f|U|0dYq zn27k&)Zfo_a+=L<<218mXl7OofOd(7!G?(V*3t(Tr;|d1j)J)+U^)oebLnEaQ8>+n zplHn{y&EkZRV6CMus|i}>Z$?X>%)hj`1qf_`cq6`hby-YknQ10#`PukgB76G!%fOo zE*gYXOYr-RYZFw3s)90w9?dI z3L|}elhe2?Mx~uxme@6nMv@z{7MRT^YBZCdUvUwrgv>-t*9%f{%7V8mh1(?T^Bo~# z{<9<7hNd2x8L2xr%T){YT0U=kIB#z16ufP?n&#hRY-q|*wgY0CjiHSE96P|+A<4d2 zOtl^r&7Y3ca<$C~Im zq9j4Y<8(Xug~?(2ND;tkVX(8kOmIuj&tNFqCw$#JQOSaU^v_ARV{5h7-~^Lu6qLNW z87nW(7_ch-ox`*^Q-fdYjqRBQ zqpK=JyaLDvhlVVAQ#1LC4OR52twz?re}BksVX^z+wpzp3n7Y3dPqd05V4N^o$yF*) zi0Be=V4ZV@@qh$xsw-G^n-1oMxtUI6mC)V;d>GH;^l(51Q*8MZ&Pmvzi`^e#ulXiD z!v99+RW9w8V13khc^*dB(~}-Ns#>aKm)H)cijC%uG9*`xQc%S9M#X-}p(T#4fz(6w zeJU76S|j#-;Cz+5+5Nr8Q2y-Quo6x)F$($iBE0Pl$LE(&$y(L2M9f_6abrEys^-9i z1M@a{`5AVM;O(k3us7#^*F8OcxXq5yy4|dum}5EuZstMeIL+hpjJE`Ajo3MB&uWZ+ z9Z0BwIVn!_$nwu1QA~^wpVom;Nm*Nh1VnD=#igY!LyW zg?z;q;PwH?qql7`V}SxG!Tapl`S>5&Pz-xV?0;+u$Qv15EGn{LL!>?{m{kUpD~(`b zuCH8*WMTahF0RMPMCG;4tJAvayRBrwg$h_qq6qp!3#f<00*n_|?AYCAu%zZ~KeAmO zE-vr|)@i(2CQBt8%yAohAac2qv5<>Qt;I%nfg6-Z=`t}zT7TYU>x<-#r}=OG2=w3r z7ZUvc43ht^(E2qmITnHBfjdjNtgz5zu&^OpYJX$WOxJANMaK+~_9{-`#61=#$h~@?^OUZ!r zR~g_#(ooZIhBnnVK2W>Y9 zI6Q7@Y8V+AQO?tFsz}iVdI4zj2Kb?Ic0i8Fsj8|C^`4CH)x8JDA%w;TVjzxZ6|o2PX|%ic{KrJDei zBlIRM2lX>2hV{|XzP`Rn2$YFo0<03oi?-nALc#BVwP!I!!qIXruFK(HEFh!zOJ!wc zwD}kKlN3VsN(QherG&%$YJ6}IfN*Q{{!}&{jD@7a*@9j_CkzJ()t`w9PKOx&4ZUJ) zi}!82LDQ!?Gg`66mE(HAU#gQ5YN|sZ0SEH)UiwwtMnC1#2-nQx&H4!;w;4ESXoXtL zLex(X>9&%+TjTpqqqmqPO>O|HtOp|DqLDTO*@8BqR)E5Y}_N$g18uWIU8^c5MF#QA$ansRXrz zai%svRzfcOvQbBFh?0VoCZ$}3hF`y^)#Ra>$EOf3wjC+lSqBXzLzI6>31DF#p7R7y z3Lz?74|mCi{!AnemSI1nNE6~LJXm@GMMDLD1>^pi*A4U0szZ-FW`|<{Wgwf!-Rwu8 z-J*ekq~ylUz?#jCY8S#d@r%=l^mY*G=H@6AdaE{}{=&zP5dB_O)Rubk_vfo1iGV3u z-&m8F{VBu-@Su-=n^Xi75fv3XmMo;Nj#YSFfEK$Hp)-511-az1 zva;c8qFF7c0C2hk`N;KTy$npH|E%`uIS?{AiiL+0bFeD>5Qni0Fn)$dM#5Os&`_S& zN>D6O+W;!@JRKxqN~ffnLPAj><~DdBCoUeCnQJELn3SKu0iF&Tu8sWOjCR+q`*E0R zi!BXZM+(>-y4EFrfccMr+fgxGv7>fh#8ZliIT*u1-4kH~ixk8kK{fGO*($7kcMq#< z3?+ESg~pHFdTRA!1`}LIv7%Um%pU3JM=39*D(*A3!w^%w%#=CT44}HwfgIx^81hS9 zf>s&Qbz0hwu9G2vM^h?#jC$#J?dsJ+2ml#L@xpehI8;L5T9p#16tATv!j9;lU50}O#~8CTLk;i$x$}C%gEGNzfdXJE0ljUYM`g5WzFL}ctcs+ z8f!z0H?tu1ulo8_#U_8r)nijCL089_+0OvyeIxq&QlnY;x;|WD0}Z=}x#?g#un?eE4NiK%_*6kBN={l@que(o(dhHUfZ58zP>Wq zDZ8FEl!XkfV}D(jB5nI!Ur(E>hpv|(YK@5a^i2JjJML+}n7kInIofJW=b^sIn>Usa zIinhJm+Y)Omxx()Z)IXE%}72XS=()9aH^?k2?9Uy+_nChZ~!vle(7TB*L`E7oyHHW zz%q;I>goCYdk*s%uT>;nI1a`skx3fsFK3pP@-f`cylQH+Jo%j~80mlw*E)8(y-eCr>4-V7QX47+0!7=yc%z@^FX^u? zq4v{>Kq$I``u=u7a>31Ub%n7HsE29nn4Lbku>LI$u&&6{3^AUpa-u-KrUq_GawFWl1J7h zL!Ks6A5Oq&ACORUw`2X`bGt-LZ`YO|9owVS9$L7MCg+Zo#_>5lGvM&(W5q5%S6aC1 z{u?5WiD_xvdfoR(NJvWNVF&=kS8!cPt&x~lw!V-6fMw%pG2>xCorL zzkXQJ)6>ro5Y(E#&d2Nu`p4y(!BIBAuL)5&u%$)y95R!R6glL>V4|X;;v?>LrdNsY z5q!qKo@ewYKBqIBTP+E_FFsag3hCc{#T^|-R*PslsR7&w;29!NT609yp!btw|oaCNuxsc->HZ$Vk$QRogKac}0zS64{Gk(XM< z07DsQJYUas-L>23&vdVLegsA*YwIvb0X%52JtjuQtS`LdLd~651U1_$wg8AQm6V<}y78?0(^>LmZx@ooQw`n;>;Y>U{DyykxMy>ssQtn!`5*^Dy zDbD+=L{uc~0$^&z#IR?udT*fhNFE-eOV^xD(dAc^UZ-(&aHd{Sy)@YD+T} z=%nmWgB@L67Et*xTV(2dyllPA?r;or)Yk9IP@lo+Ze3;pV63qY0TGei+@fD*UwVsY z@9KDkn*ayLugS^I)?P^_N%5_NmbnBHc7A)za7^jzas~Y%4t=OM!1l?PkkzYSX4{`90ktKWTk=O849Quzix`>SoqkmH505l${0)g<%$QszloNKHb?WvprJ}ZcoRWa zhq`sEx_Dvmmo#JttR`Of7Z(@n3=JVn50|311`0Q5+rggS*LQzIn}_m(PYKVS9@_ep zp-eIs2nKQ=$Q0noQ7Qsl-E$QKLD-~p_kVH4mE!_&(pU2s4zLV9xdtm;zr4Zz|2XjD z@&7&-PTA3A*XL9%Xswm&-rmye{vIo1vjLh00CD^X@-%)<&TBVrRQL88!EgTY7`jP# z`7)?Lni=2O{|WZi1W_+Lb8|F&r>cQLeMg6+YT`4{0z7>Q38{Fkk6W>;Pe3K8e(*pV zbfrq*Z+JgSak5aZ0-QCg@gO!*BMOVVx#_{T9HpkFbggbMK7TXjaE2_dtl+K2UQa7< z{?z)NgHJ;)Jb&}sfv3PSI&?1U+%KoW`4<$1z~8U7|BwG9P3PD9`ak$eaB{61>UV-$ zuD!F@2fWVV=FP!L!gx<tP)$;yLXKA^dNV1#)yk@4M?$! z%ynlU^gW2N?%A!-v{X15%qS6OvXLqmk|^x1VF5)2zF38!ktJ;)vC@+*To@MX$QXXz zDy=LHMQi#ao}N2Npgo6qF45ZDTszan4ilz$np=^zKz=Idco4*z0+?jt=L8#q0JrDL zm@F+Vp-s%k-p)J<)d>?~WyR>d7`EA$UUr>jnCR%97mX2=F*kzUBnup(FDvc3Q_M9U zj%@{;1pUE;4ctS{&R^wGz&!euBj8-{nUC^Uj$kRzKV^s*6x0Gc6r>};KEGZ^k9a5U z4H66MZvO1a)E+2dmIk`9H@4DhvJc9|!PYPTLcPo^E7JIMqA_S)FWhvDO!oDKoGyT# zYe9_%U5|kRyKH?aaFr!w>8A%mGLLOpyJb?NZm!m3bMzp_QWYj@mMvTDxLTw0N#yZ2 zFk4clk?Dx2uD*3M`7fAeW$UHh1^Ej^3MJ^&4-9Z|aLB6&X>4A+ra&!RQ1DN0Upvc? z((;DeJ(_4El*h0C>60bl0>tgyL)14nbA#Isrqr=TKrUj@2CMfUbljQ?M``V<#Dfm zpajE$1Xop&m}^iXwKU}Z{X;u}dB|Z|;Sd1cVE$fF<`Ho>i1iEW_m-CVUi99~$~aY) zgV7OMS~OJguttH1f=egoEIk9apP0+yOnYv8k(ITDwb>&rke(Oa1-z*SlCeICxvT9R`0;E-dM^f0Ua7IgDt zFZ}6|-a8cG0CaxR*XUa+s8$1yTV4 z3XYEs`ugrU86CKFcxh#S@KOBl%f3b(bE9TH(9}cHCyct=#<={B(cfceZ~{fq(K8%B zAVYQ%ABAFeu^(LA8LOi|M8yHwgapLrvk+t(awg;hi&z#~Mg&fHVVC?s+e9Q#m41No zZ=D$>?6KQdSxx{@y}gZ1xm~@>{;u)f3PRixikJG@%e1qB!~`8BP&IfDyqRsn&aXx_ zUjfygX8FCfjFvB7YF!rxFcu5?fB&GH47>k(-K+njfP2;Zb$_G%&j=dbZ039IKI)~q z=vd#2ySh>CP`}$|E&KYjZGX{q>Cv3nHP|~onk9)M=YVeG%5Gpz(y|G)-809l5bP!B z`R>QnHR@r(3H4FUy&u=$Y{<~-VAWlayNzs0vr3f<(v|!qE5#2x zxL>#m6b$SEZuP?=)ak;CPohjadq_K6*ytmy*`Z(8z}$MI!x}%XKkV?cA74R znH9Os+QVaeW#!3U+83MHv+-IV@4MqfBS5a-s{;r9_GkdEco8*-wDAJSF$f70Ku3(q z7rG7L)7)0*0`H1=E`~h4@ z1jLQ2Wy6D%ioo-OzbN>ESb_D1BWwgIkp_5v&=0@x_XqQsXMJn#IenLTq4zp11A!^X zwVt5Z!i-8Qs)3Rv$UM%;TO1kr^pJR-RXTOh)m|lDQdaTh@$-Cy4m#tC<3|}q=H`2H z2Z|pJH^E;)@PpN<&}_O>R~R`bQ<5h!R5im}-j!$P57CI5Gu`YFrf3BE_L4ElY^wg8 zQ*VyZ>6o~XH|K5|)9_xtJk?8W19UI_4-ZBcaUKf;mC_Ou{6mMEVgp{i>Eo5Fu<_K_ zbaXTmsyZLG%JP!&OTbAvIA9jHTdII=aVT50q&JaTB+vnSj`vBp?fgjcAuOp%GWVv) zJLb%`;5UJcV)kjP_dsRU{?VL4Q?u~tfxXk*G5Q9BX;K}vax@tUn<9m%@Y=@s-kR@S z%J<4MGv#Uz2Z0tFgfmxN$vk>>9UZHHVLWz<1Nz@S>wTk`c^%ax!R%Y)XPmhzNg(z| z8+$rCMpu#N%Dss1jH`}!^|UvB@`QBUd0b%<7Mqli;H~A{HGs)xe;T@~>w{p&toglD z=uZb3Tr`jRN2L-P4ah+{LgbxDLa6;{#>}JO=E^xmv-R-thV@N&D>#Y725b_vz>0W# zay8M8lh#1!($QeP66VtfRauNM{{p@+O-jOJJs&`d2jnmDipb8oZk13QShgghh)wAg z?yKY*J}W;=4khkLq7AdG(TUz5ZUWsuwBftr(70=jf5qRhzZ}1|{8$(6vjY`Z;7s`j znlQ)CaIjr(Z6$^z34^6A{nWH|fo18o5A(sBz3` zvY@ZPE@BAXl$$!lVc)kkUtMGGQRb24Iv1DxrAB(^BpsdPPEr1;ri4#B+(HqK=tS~- z{*n9K$8bq)oG$AhD#4l_p9UILbj2lILO+j=hC_# zO~bUCo|l*S^=`R~gTp{tDO_ka6*|8LITazeUBWW~)1XX7?UE09vU7SOX`$J5FF~@s z($dnEP!IhpU5!*WM%sA9z>A)5}Svh-z!~J)n?QE(cpvH2|RsU=iEkomrLar7dNVb zT}hoBZCntxJLZp${(HBt(L5W4=W3=p*q91n_2cm)&Fu7WfSrcGX*{T#+A6OEenlM}EI?o2jU zTX$Q3P=Va5>i$Oe)}dIstLA;avr^x6#2%Vmwf`=IJP>TIq?^qSZUv~Z3rf~dahUCx z(~233!#b#oUe0qQ*ZBKE(5)vcjl6Ce91YGS6u3VY!C0E_F?m9|+%&5?%8~jDEfam1R zk7+2A>%a)FW!+SzprU8wa$iX)`$5eY6q!HcUObzBXyZl8Ey7Rz48ZSnho>J)uhGtq zK~y=awNzw^j)OvE7f2r8s{CK7IOO=(ERN z3FnJc)2g6(q2`L{=;EQ@xg4a=7daL;sd1D_7iRFn_I=4`Sm?%$Ws=SOEbg;=uK8<0 zP_#!e^h*2FrXko$>StNwzq7NmTaz|^+>VodP2Yf@#*#eDw6xYoS*Zk$dyXzF7=4tP zefGz(Mwf+!pYz>2GOgj;6LmIiF9Y(Q%`?>SyE8TFk>s*ZubaZ1(jErpwVrz3t+?nhGmoC+E(YQ02i+c9#7SSm{ zA{NQCa|$n+g@14Ux<_@0nSa;4D!e}|L%EYIp+t9ro<3fXrumuQTI8PW!xMUwrAupj zrKXO5qAm6xK(fehG-l2#9qy&MxoTBowQ^v)PH(0k8ag~TKkHClIjNHE=I#zpUIX*+ zSLv=87WXq5#+GSo$$wnyL*4d}UWxS3h@@L2xVQ-hzkI6xnTjYQsdj%JH$Y z7gCm3?mZ~TG;v0E^D61Dx~h6kKQ(Rq&M>z-jJG14!LJEP;7;@T;fx3?}(Q4 z*!`4aBf`x+4@-O$ta7(Ws_aziNh|IP{h{fD#SX*e4z4eAZSnc(cZiB4+~MH!Ybq~g z!2y>JQ!hR`Q5bhFgARxqmcpO8yI-5i=WykCFmYd2Id_FBJLBxRip?3A*%xlzQmOK~ z2eEQ(iNuOc0yXt9c)lAy1%5qBcVGiKA4|T%r<3y{B!uw(A_=5<0$ww(_!7#qOkbJ# z<#1f{hj@TEnIh}{sfn!dS#J6D)YP4ly>TLVttBK#McM-W`7lg}Ebo2y=CBY9@@%$< z*ifl2h>EwcKtaSQlU-?ir?w|^MYPOCSo5$mQu6l6F!qiSLf6iAUg^~R?6DDO6I%d* zPEqj-$F=MkDouKNdh*mOEmp?Zi!jD(A$s;-BqSsP z&mc>k2*u%lB%362h8UgVwo0aQ`bRGy4CUo71haJMC1ZQ=b;GDhpu4kE3A-L5bq|ij z&9RY1GppT71+>P)iE}BGTE{=hyGLg6+vIiBkeP0;qqR9Ra}wMrjcx6dYl}@{VS`pB zs6+Y~TL6NbKXES0<*QpZ>{fjZ!XkS4CC%4IUe|=ne8{bXp`AcmI{6Z;N>>&`V3}tf zuYI$*xy#v#(q#aO}J%AN{*i$V*1S)EtdHn)FveiJ}o6p_Eyr#rMX6)(Yu zneNx!ZKKEMgDW_LUwiC*WA$C}V)WIZHGL_%fS*|NpS{EhN zYAMNbCypM)58g0svemS_f@6(X(Ixasd&K z8%oca8W013Pwd3oy?^oBiLeu~y2e!x6(CB!H@Ba|zruzPe7ucGd}IC3qcj>+IAQly zx1~f9BtK;Gc`WqbFZOExaS@@yxK{~HqRJgthsKG~t2GDM?-#pP)<#C&Q#16M3wP+22btaBDuLPhLqF!pACRNVqp7+BA->z2 zPZ)$;I&@iIR&CDc!ND zG?=&!-rm*?ZE$EWlw&4NL(9=;wQB`3FNjp*Gc#K)ufjw{2k7WXWvJX$L7hB9E~~mg za~l|cMf2^5NE4DsRGY>2-5H8e)`Y!nmAW`gHc*HQH)DcOD1J5#_s(DimqOF&;?{(+ zCb?DsZb6?*qMgF)e@_J5h39lD@(=W*?gAB@=!U<~&HFVz+6kf?+Z)6b8v@V9eAtkT z_bGYDXv)!H5p>Z82fY3?d;>=x!LbvkC(Ph@9OgVemCFG1D#-ECyFLxh7u>L1knr5nEbj=F<3hrQ?Rc{-IXmKgj0(FPrrK zOSxn4$xtw7!-qV}cblY4&7-E{PjE-`S8mP^)N{w+q5kzw6#pZVkZBcBm8~DpI3A$7JURB2+jiJlT`?x+45Nqr>1oFvzlDDle)jCLb1 z$g>0mDCD`=`NweUIxLhm(lQ&X4{M+=MA+ITnBhoNI$7*YVqTpeC*KD%RSkc7Qno5- zSmh-z*@SKQ@dHH68t!P6{?+@x22eEcmxhcy!0xuP%Y|%jbYx6h`Bb6dQY=2p@ z(l5JMY@0Cn>9#JUF3joD$0*{;-n7KD$wIWSwf&&r-VjkjV;FB|kuYE5d{Pc<(6 zZujMj@glCoYpLpVXXb*d3m4yM{Ts|bKHV-E_Ey5!T{pb+wod8pJJHGw6&nQ-b0+iE zt2fRGRz#QKMr+S#=PXb#JAGjhI2%F{*HFOZsK z?1mEh81|L%`QcV>y_7gWCnjNgr&xw3+OT9F}$fRAIFM@^o7>vzZ3GVnGBCIb0rw8P0@y$ zhRMpl)|tUM1*V#|Z*BE?68QT1Mda#b-Z1rQP_E?DcdzBNmQms!UUs7?qZez)bjsgj zkrj^Vcl8jxU%vF<@nYTqLvsYAQ-WY>`S-y-;Rb3fAScJKprqU$1qI{-jhC5SK+VS5 z%BsX#WM-=JoM;~@*px^KLYuhB+IjauCwTRb-2y^DPN z*@h5a?%JlE+aW_2*xSRS{qdzOp=&m>M&K^0BqPTL*Q`sk&|g0Eqxu5^Z&h5nra-Jd z=#;H(=d#njr;!>y7&@KEfpGYzxgE+b6s5+xipapk>{`ZouU zGZm;!0l${%^yz4#KHzsT804id&+%Hx-sR};-*e9Jop;&%$w%~9++92}E?8nKG6Sq- zYd?TW4p&-ceTT+kvF){LAX;9xbcI?^NkMJ-7zSORZCoC`YQY<~5%T(Vq5#H8{uS0M z;f9Hg-zv4Sfzt;6c(?s|I+M?*a^1!zrqY|M!qBuAjJGYmHd(0PbVve=tqFzj@I>3* zF-m{ntKoMnN?XI9HA{_rQb8!g3$I|bUfTt!BKOcZwOdaKpO55rqnKZxF>Y9T*D;!o zs%&*%J0*ux^pryOV`5X%a~tp}!36Z8l1`lWAZB%ncVh90LBUx}MhtYl3ShA~_?o z*9iHo8=9+n12ot_9YrMqCf~hU4Szop77MOWB`cxIX_2p7x z2VGk;FlSB^zrEmKsfP2ljG5Nk_HRW~gJr`NhSsF~ z{QiJ29bw^9sLh|im4q*5Y7iprm{?g&PkQh9&*Fx;aB4WLPy4W>58#A56=9)!09!`s z(#crDYMFzbIU?Ch8VP*bI!4C8Epg9C_jm;bPJU+h!C^-Ab`xmySs_$Z$Wo7%350^; zR!@KtL!xrCoJFE|>4j4h@d`F@hxT-IL<`?Fl9i;h{=j5#d_Ab+qu%{K{k@H+-xJC$ zO&?wY5bWDxEd*Z8ogsu75n%<)ZnF6Ano3AvI?9`-xBz?!EdM{yV<{_fFz`QW{-3AE zg2d&~zqg?r2n68lQ0IK2=+ex6=Yg(DdVZ$+pB}&Z?Ev#xdRA~%1kKbOoZtXr04vsj z;q2DWZ!IT*xej1j2nZ2&o1x#Y{v2-rY{W70>(>_msh^!?03PxPaGya7lzU%fd$g9D z((Q-ey*_flF%CHL4M6*Zs~&P>A5IJZ`tRT{2xhzloigC_141OeVV~#ih%a2O1inEc(|UxWvi|kY`vX|MdsN#lH`U)7OF@?msd!6chq#YWM+F z1HXuj+y}q{!4|HL?Vai{r{Dkh1ICyg0l+HZkN7)G4lbe@GeAmMFJHb(_OP&wf%iUw zVEdab3)grb6KV)I&#{FUBcI++fHQo$|H88W(}*}&xk7Qy-&=T3O|Tp}aztG{ zEI7CobP%APfDe=TC4|ydRz8o3@dT^d{`UeA?YR~fSBsRN_s#l&@RkD{=St|MkBT#P#3g<_1m;eEqk2<3@jo$cEVC02~oo z#RhFEF0Q8OSU9zKme>w{96?UCCThZ19om|zaRNl?u6=3O?lJJDb;^Gj-H8E27b`39 z=L3BLdoD!5G8(3u9J~op4oWfFEc69>MhhFM8P}xeUc~h=p zHTEeGh{`=S=0KOC!(#+7bpu5T0ISiZpe`ku^CT(XHG;TR9k%A~=JxyVzspyf*x1DD z-2eP|6oQOkRCT8b6uTAPUZ@>XN#)LFIwBvuMJ;hRz~~MhAQLUE$a4SsCT;FE#OKhe zRzLwl&NhsC!g4skF7Hpn3@XUjvPVF(fRHmJ;qjh#=TtD$fY<~Q)JDgS91(f4Isx=o zML#Re%pSBZBKPOaj2me2Veke1f{NK&z`DC%;=&{*CT1nKIUP{GQ!v?l-HaNC_ zvCV-nW;F4ML%3K1&G9a zXSu7In%cO9N~cgcu>_y+?wt!{kJ)XnuWtzDArPQmz}yR(yP4JxnZXin%b)Z*Gr%kw z6nEnuzF`H(VL?HONj z0?%-+w!A_EhBC3Z>C;7N_T*4THc`>-{z6OeiJL3NkU>-dV3V|Ny?k5uHH#KDT<8II z9*QD08H6?XH)q38ozSR4P&4z*zLa6;hyCz zfp`8>>RwWNrHxSI?cWfGJ={H&w*Z#PE+QNq=pP?NUL%ftdfu> zfg&TLcuix9w7Y9Y9<7xdbp08sX>DcF4xE6ZI0?`7P+X`XjC+4!m#cT~!ku>0uH!4X zg2908_CESJlfYN#pw`x=?o}VO6Dx9noU0%PA%OD$T;rn-2pb+k-VvKjAI8%)Vya~T z`SZupYqDyhIAFM7)*7R42^yJnr6`z=CkUE)%)8jd3pzj+iD2Vx7yw_+mmlb1Cx=ou z&J~>Q@2QmwN&Doopf6xlW~&Dtx@rD*G426UW+))cI*7i2vQdzxt(ZLJZL9C;z%*fHVfva4}f4f?H(u#8|^^=$+yt<_NFEXrQo7b@Q+9X zT4qW4!CGwbFer+qv!*+#d=jc}oV=nE?!xvsj0leP=G}>0^``+VljrvOEP7sZo2Ki7ajIMX!zWLkI8C)_ zF7-`Ddk%zLht4|CMI#t*E5hx0(cM2YrWc-p4=NIcRK2{jkh24QeP@V)?V!9 zgJ$6HNN%iy%gz>@T?3LY{QSJHuZkh1VWacS*+%`ZoVPHU%dT93;5$4g>ji5Ai z$aJZP)Gsv(Z!tAJ9XZ>P9)u-jL^Fzbtb>P0Kr=-qfX6{m1TcF7NY1%%9Y}amU`O7F z;3-Kq#G<4#WM7|B;N~>^pw?uMuvp$-@wZscK4;B@#Z{f^t-PwEKPbkt*MP{<-P402 z?-Idv-pTc0+!!e0vTUo8x|spfLtmHkb8C(IDp()hpazLYH)OF z`s^;8{xs-fOA7@Bxb6y+SHmvgJfW(&Vc+Gp=Sy0EAC({@SlKRzX3Mz0g(_!Ud9RTKy zOJq$Ua5_Ld&yZ*!b?k_}j)xnE!|X7uc-h(!Z1TSayVwoFBndR`1b_wTIn=O5DYd77 zf-?N6bcSNnbskkW$^9iv@-L{(Ks=Mtf-p4zy}|TOqb6Xv2=3FZ41)Gr$GZ7w!SD&A zbr8^r8fS2EauQVx6c{k;m=Y3bdYe48VZ*S}^!Zd=O!McTpP#H*$jig2*10`}4^}rF zZu!UWUtV50veG2w@^kCj5#g>(tu7VAGG_&MB?`alskEeG(;2mBI|@Rk6-x9HE4xD>j3qinZ!0GXbMlU;X+$u=_6ztC=n?4>EdiK2-cyp4o4a6{gwkj6IoLlIhqQ?* zDH<&a0<3O0Zm|cG4Rme@`r8g-fZ7wbQU4m2RFNUDe*h^F0(XTTjoiXaOtpY^$Jse@ zgw>ju)g%s`)J0A`GHZ6O`_*9EO->AUw|Ckgy9VkdmXUkySWc+Y{X24KEnhL++4@M zUnI=wqSN2FP?mETEPO<={WGJru>g>Rv7jop(73|GlYS5GN}w`jM8DbHEa%eBZh8ad zs=nju1ZOO$14I^>_<#&&h5Hx>h z;*zK+dueDyM6#jxWuP(N9VPC66kF9GNt*_cb_$4h2ksBPVUn2IVa`x&Oksb`&eD)1 zwU^>|46#MDvatd;GB8K*70CI`gh5C#(gX$E#ZlQoLA$xGJSIR|xXxx|WWa3=h6v60 z1Oen6g_X{lDi=J5%hvTWtk9RE`wJ5xe2Rllew>J>z*$ngwH+{sD9CtPofb|;5qwYuu zPH6K^A~0XQ#T&B;`y{_7kfut#CXfEAL27IdrtP*miJkdfGC6P!LKr2+l3=^u}#BUdZSU`*B-<=7{9P zXS$OSFt#-)acEqxsr7H#D*!lcrfL%0L(zZUH88l6D-NiS_Eb6WfNK8zSf=eq%xqna z<})_z!Tel};?~JA%%VK92PDs4P(LVn3Rd-)6uz*qurvnBe_c)nBiC}W7pWVa?%x5KppAsBu(en$ob}A|=wXBy=Mp$FY(SJWPuX@K4O6Nz9X2!=0L7UvH%lFHm zK>LQ0w*+5LznLEYFFLr8Y2oCzr`YwK^86pB94QYS_^UrXN=T4te??G`y(g1*=fSgo E1I)~01ONa4 literal 0 HcmV?d00001 diff --git a/docs/images/goals-mobile.png b/docs/images/goals-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..7e6e5e5fde22420a1d8a7226895fee88dd30a00f GIT binary patch literal 42658 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 W9^r)fM_S=GRO*+mtL3R!;Qs^IXPtup literal 0 HcmV?d00001 From 6fa232c0b4eeacd293d17126907cd932b843a05e Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Tue, 6 Oct 2026 17:40:43 +0300 Subject: [PATCH 2/4] fix: preserve goal history through configuration replacements --- app/lib/providers/goals_provider.dart | 51 ++++++++--- app/lib/widgets/goals/add_goal_sheet.dart | 57 +++++++++--- app/test/goals/goals_provider_test.dart | 45 +++++++++ .../goals/goal_details_sheets_test.dart | 91 +++++++++++++++++++ docs/goals-tracking.md | 3 +- 5 files changed, 219 insertions(+), 28 deletions(-) diff --git a/app/lib/providers/goals_provider.dart b/app/lib/providers/goals_provider.dart index 6271d37d..e607cd42 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -117,8 +117,12 @@ class GoalsProvider extends ChangeNotifier { String? scopeId, int minimumMinutes = 5, String? timezone, + String? replaceGoalId, TrackingRepository? repository, }) async { + final origin = repository ?? store.trackingRepository; + final replacing = replaceGoalId == null ? null : store.getReadingGoal(replaceGoalId); + if (replaceGoalId != null && replacing == null) throw StateError('This goal no longer exists.'); if (target < 1 || minimumMinutes < 1 || minimumMinutes > 1440) throw ArgumentError('Targets must be positive.'); if (scope != GoalScope.library && scopeId == null) throw ArgumentError('Choose a book or shelf.'); final zone = timezone ?? await GoalCalendar.deviceTimezone(); @@ -143,7 +147,10 @@ class GoalsProvider extends ChangeNotifier { isRecurring: period != GoalPeriod.custom && isRecurring, rules: [GoalRule(at: created, target: target, title: title?.trim().isEmpty == true ? null : title)], ); - await store.commitTracking(goals: [goal], repository: repository); + await store.commitTracking( + goals: [if (replacing != null) _revisedGoal(replacing, archived: true, active: false), goal], + repository: origin, + ); } Future updateGoal({ @@ -155,7 +162,23 @@ class GoalsProvider extends ChangeNotifier { }) async { final goal = store.getReadingGoal(goalId); if (goal == null) throw StateError('This goal no longer exists.'); - if (type != null && type != goal.type) throw StateError('Create a replacement to change the metric.'); + if (type != null && type != goal.type) { + await createGoal( + type: type, + target: target ?? goal.targetValue, + period: goal.period, + isRecurring: goal.isRecurring, + endDate: goal.endDate, + title: title ?? goal.title, + scope: goal.scope, + scopeId: goal.scopeId, + minimumMinutes: goal.minimumMinutes, + timezone: goal.timezone, + replaceGoalId: goalId, + repository: repository, + ); + return; + } if (target != null && target < 1) throw ArgumentError('Target must be positive.'); await _revise(goal, target: target, title: title, repository: repository); } @@ -167,7 +190,12 @@ class GoalsProvider extends ChangeNotifier { bool? active, bool? archived, TrackingRepository? repository, - }) async { + }) => store.commitTracking( + goals: [_revisedGoal(goal, target: target, title: title, active: active, archived: archived)], + repository: repository, + ); + + ReadingGoal _revisedGoal(ReadingGoal goal, {int? target, String? title, bool? active, bool? archived}) { var at = now; final rules = goal.rules.isEmpty ? [ @@ -188,17 +216,12 @@ class GoalsProvider extends ChangeNotifier { active: active ?? goal.isActive, archived: archived ?? goal.isArchived, ); - await store.commitTracking( - goals: [ - goal.copyWith( - targetValue: rule.target, - title: rule.title, - isActive: rule.active, - isArchived: rule.archived, - rules: [...rules, rule], - ), - ], - repository: repository, + return goal.copyWith( + targetValue: rule.target, + title: rule.title, + isActive: rule.active, + isArchived: rule.archived, + rules: [...rules, rule], ); } diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index e70d6d8e..97689cd6 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -67,6 +67,7 @@ class _AddGoalSheetState extends State { _title.text = editing.title ?? ''; _threshold.text = '${editing.minimumMinutes}'; _zone.text = editing.timezone; + _deadline = GoalCalendar.local(editing.endDate.subtract(const Duration(microseconds: 1)), editing.timezone); _timezoneReady = true; } else if (widget.initialTimezone != null) { _zone.text = widget.initialTimezone!; @@ -185,7 +186,7 @@ class _AddGoalSheetState extends State { ), ), ], - onChanged: editing || _saving + onChanged: _saving ? null : (value) => setState(() { _type = value!; @@ -221,7 +222,7 @@ class _AddGoalSheetState extends State { ), ), ], - onChanged: editing || _saving + onChanged: _saving ? null : (value) => setState(() { _period = value!; @@ -232,12 +233,12 @@ class _AddGoalSheetState extends State { Padding( padding: const EdgeInsets.only(top: Spacing.md), child: OutlinedButton.icon( - onPressed: editing || _saving + onPressed: _saving ? null : () async { final date = await showAppDatePicker( context: context, - initialDate: _deadline, + initialDate: _deadline.isBefore(DateTime.now()) ? DateTime.now() : _deadline, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 3650)), ); @@ -252,7 +253,7 @@ class _AddGoalSheetState extends State { contentPadding: EdgeInsets.zero, title: const Text('Repeat each period'), value: _recurring, - onChanged: editing || _saving ? null : (value) => setState(() => _recurring = value), + onChanged: _saving ? null : (value) => setState(() => _recurring = value), ), const SizedBox(height: Spacing.md), DropdownButtonFormField( @@ -273,7 +274,7 @@ class _AddGoalSheetState extends State { ), ), ], - onChanged: editing || _saving + onChanged: _saving ? null : (value) => setState(() { _scope = value!; @@ -296,14 +297,14 @@ class _AddGoalSheetState extends State { ], validator: (value) => value == null ? 'Choose ${_scope == GoalScope.book ? 'a book' : 'a shelf'}.' : null, - onChanged: editing || _saving ? null : (value) => setState(() => _scopeId = value), + onChanged: _saving ? null : (value) => setState(() => _scopeId = value), ), ], if (_type == GoalType.days) ...[ const SizedBox(height: Spacing.md), TextFormField( controller: _threshold, - enabled: !editing, + enabled: !_saving, keyboardType: TextInputType.number, decoration: const InputDecoration( labelText: 'Minimum minutes per reading day', @@ -313,16 +314,18 @@ class _AddGoalSheetState extends State { final n = int.tryParse(value ?? ''); return n == null || n < 1 || n > 1440 ? 'Enter 1–1,440 minutes.' : null; }, + onChanged: (_) => setState(() {}), ), ], const SizedBox(height: Spacing.md), TextFormField( controller: _zone, - enabled: !editing, + enabled: !_saving, decoration: const InputDecoration( labelText: 'Timezone', helperText: 'Calendar periods follow this timezone on every device.', ), + onChanged: (_) => setState(() {}), validator: (value) { try { GoalCalendar.location(value ?? ''); @@ -333,11 +336,18 @@ class _AddGoalSheetState extends State { }, ), const SizedBox(height: Spacing.lg), - Text(preview.displayTitle, style: Theme.of(context).textTheme.titleMedium), + Text('Preview', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + Text( + '${preview.description}. ${_scope == GoalScope.library ? 'Across your library' : items[_scopeId] ?? 'Choose a book or shelf'}.', + style: Theme.of(context).textTheme.bodyLarge, + ), const SizedBox(height: Spacing.sm), Text( editing - ? 'Past periods keep their original target. To change the measure, schedule, or scope, create a new goal.' + ? (_replacementNeeded + ? 'This change starts a replacement goal. The current goal is archived with its history; only new reading counts toward the replacement.' + : 'Past periods keep their original target.') : 'Only reading after this goal is created counts. Progress comes from the reader and your manual logs.', style: Theme.of(context).textTheme.bodyMedium, ), @@ -360,12 +370,32 @@ class _AddGoalSheetState extends State { saveLabel: _saving ? 'Saving…' : editing - ? 'Save' + ? (_replacementNeeded ? 'Replace goal' : 'Save') : 'Create goal', ), ); } + bool get _replacementNeeded { + final goal = widget.editing; + if (goal == null) return false; + final zone = _zone.text.trim(); + final originalDeadline = GoalCalendar.local(goal.endDate.subtract(const Duration(microseconds: 1)), goal.timezone); + final deadlineChanged = + _period == GoalPeriod.custom && + (_deadline.year != originalDeadline.year || + _deadline.month != originalDeadline.month || + _deadline.day != originalDeadline.day); + return _type != goal.type || + _period != goal.period || + _scope != goal.scope || + _scopeId != goal.scopeId || + _recurring != goal.isRecurring || + zone != goal.timezone || + int.tryParse(_threshold.text) != goal.minimumMinutes || + deadlineChanged; + } + Future _save() async { if (!_form.currentState!.validate()) return; setState(() { @@ -373,7 +403,7 @@ class _AddGoalSheetState extends State { _error = null; }); try { - if (widget.editing != null) { + if (widget.editing != null && !_replacementNeeded) { await widget.provider.updateGoal( goalId: widget.editing!.id, target: int.parse(_target.text), @@ -382,6 +412,7 @@ class _AddGoalSheetState extends State { ); } else { await widget.provider.createGoal( + replaceGoalId: widget.editing?.id, type: _type, target: int.parse(_target.text), period: _period, diff --git a/app/test/goals/goals_provider_test.dart b/app/test/goals/goals_provider_test.dart index 3f587e42..6b277734 100644 --- a/app/test/goals/goals_provider_test.dart +++ b/app/test/goals/goals_provider_test.dart @@ -31,6 +31,51 @@ void main() { await ReadingDeviceIdentity.initialize(preferences); expect(ReadingDeviceIdentity.current, 'installation-one'); }); + test('configuration replacement archives the old rules and starts at the replacement cutoff', () async { + var now = DateTime.utc(2026, 10, 5, 12); + final book = Book(id: 'book', title: 'Book', author: '', addedAt: now); + final store = DataStore()..loadData(books: [book]); + final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await provider.createGoal(type: GoalType.minutes, target: 30, period: GoalPeriod.daily, timezone: 'UTC'); + final original = store.goalDefinitions.single; + now = now.add(const Duration(hours: 1)); + await provider.logReading(book: book, end: now, minutes: 10, pages: 12); + now = now.add(const Duration(hours: 1)); + await expectLater( + provider.createGoal( + type: GoalType.pages, + target: 100, + period: GoalPeriod.weekly, + scope: GoalScope.book, + replaceGoalId: original.id, + timezone: 'UTC', + ), + throwsArgumentError, + ); + expect(store.goalDefinitions.single.isArchived, isFalse); + await provider.createGoal( + type: GoalType.pages, + target: 100, + period: GoalPeriod.weekly, + scope: GoalScope.book, + scopeId: book.id, + replaceGoalId: original.id, + timezone: 'UTC', + ); + final retained = store.getReadingGoal(original.id)!; + expect(retained.isArchived, isTrue); + expect(retained.type, GoalType.minutes); + expect(provider.progress(retained).seconds, 600); + expect(store.readingActivities.length, 1); + expect(provider.current.single.pages, 0); + expect(provider.current.single.goal.createdAt, now); + now = now.add(const Duration(hours: 1)); + await provider.logReading(book: store.getBook(book.id)!, end: now, pages: 5); + expect(provider.current.single.pages, 5); + expect(provider.progress(retained).seconds, 600); + }); test('recurrence, manual correction, deletion and completion undo retain durable history', () async { var now = DateTime.utc(2026, 10, 5, 12); final book = Book(id: 'book', title: 'Physical book', author: '', addedAt: now, isPhysical: true); diff --git a/app/test/widgets/goals/goal_details_sheets_test.dart b/app/test/widgets/goals/goal_details_sheets_test.dart index 119fcf18..7128e3a6 100644 --- a/app/test/widgets/goals/goal_details_sheets_test.dart +++ b/app/test/widgets/goals/goal_details_sheets_test.dart @@ -1,13 +1,104 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; +import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; void main() { + testWidgets('editing a metric previews replacement and retains the original goal', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 1400); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final goal = ReadingGoal( + id: 'original', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + startDate: now, + endDate: now.add(const Duration(days: 1)), + timezone: 'UTC', + ); + final store = DataStore()..loadData(readingGoals: [goal]); + final provider = GoalsProvider(watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => AddGoalSheet.show(context, provider: provider, editing: goal), + child: const Text('Edit'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pages read').last); + await tester.pumpAndSettle(); + expect(find.textContaining('starts a replacement goal'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Replace goal')); + await tester.pumpAndSettle(); + expect(store.getReadingGoal('original')!.isArchived, isTrue); + expect(provider.current.single.goal.type, GoalType.pages); + expect(tester.takeException(), isNull); + }); + testWidgets('an unchanged DST deadline remains a target edit rather than a replacement', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1000, 1400); + addTearDown(tester.view.reset); + final now = DateTime.utc(2026, 3, 28, 12); + final end = GoalCalendar.deadline(DateTime(2026, 3, 29), 'Europe/Vilnius'); + final goal = ReadingGoal( + id: 'deadline', + type: GoalType.books, + targetValue: 1, + period: GoalPeriod.custom, + startDate: now, + endDate: end, + timezone: 'Europe/Vilnius', + isRecurring: false, + ); + final store = DataStore()..loadData(readingGoals: [goal]); + final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => AddGoalSheet.show(context, provider: provider, editing: goal), + child: const Text('Edit'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Edit')); + await tester.pumpAndSettle(); + expect(find.text('Deadline: 29/3/2026'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Replace goal'), findsNothing); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.length, 1); + expect(store.getReadingGoal('deadline')!.endDate, end); + expect(store.getReadingGoal('deadline')!.isArchived, isFalse); + expect(tester.takeException(), isNull); + }); for (final width in [320.0, 390.0, 1200.0]) { for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { testWidgets('goal sheet footer is above system navigation at $width/${theme.brightness}', (tester) async { diff --git a/docs/goals-tracking.md b/docs/goals-tracking.md index f07a2af3..6c58a85b 100644 --- a/docs/goals-tracking.md +++ b/docs/goals-tracking.md @@ -30,7 +30,8 @@ Goal definitions capture an IANA timezone, creation time, scope, and rule histor Monday weeks and calendar days/months/years respect timezone transitions. Activity before creation or during a pause does not count. Target/title revisions affect the current and future periods; metric, cadence, scope, timezone, and reading-day -threshold changes require a replacement goal. Recurring goals remain visible after +threshold changes save as a replacement goal, atomically archiving the original +definition with its history. The edit sheet previews this before saving. Recurring goals remain visible after achievement and advance at calendar boundaries. Archive and deletion preserve period history; activity retains book identity/title and shelf snapshots after book deletion. From 81903e644bed5842575505a995a6d2f57ee60287 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Wed, 7 Oct 2026 01:30:45 +0300 Subject: [PATCH 3/4] feat: refine goals overview, activity and creation --- app/lib/goals/reading_activity_calendar.dart | 59 ++ app/lib/pages/goals_page.dart | 541 +++++++++++------ app/lib/providers/goals_provider.dart | 7 +- app/lib/widgets/goals/add_goal_sheet.dart | 549 +++++++++++------- app/lib/widgets/goals/goal_card.dart | 117 +++- app/lib/widgets/goals/goal_controls.dart | 29 + app/lib/widgets/goals/goal_details_sheet.dart | 170 +++--- .../widgets/goals/goal_timezone_picker.dart | 93 +++ app/lib/widgets/goals/log_reading_sheet.dart | 473 +++++++++------ .../goals/reading_activity_heatmap.dart | 338 +++++++++++ .../goals/reading_activity_timeline.dart | 172 ++++++ .../goals/reading_activity_calendar_test.dart | 68 +++ app/test/pages/goals_page_test.dart | 44 +- .../goals/goals_ui_refinement_test.dart | 387 ++++++++++++ .../shared/bottom_sheet_expansion_test.dart | 4 +- .../shared/bottom_sheet_layouts_test.dart | 3 +- 16 files changed, 2359 insertions(+), 695 deletions(-) create mode 100644 app/lib/goals/reading_activity_calendar.dart create mode 100644 app/lib/widgets/goals/goal_controls.dart create mode 100644 app/lib/widgets/goals/goal_timezone_picker.dart create mode 100644 app/lib/widgets/goals/reading_activity_heatmap.dart create mode 100644 app/lib/widgets/goals/reading_activity_timeline.dart create mode 100644 app/test/goals/reading_activity_calendar_test.dart create mode 100644 app/test/widgets/goals/goals_ui_refinement_test.dart diff --git a/app/lib/goals/reading_activity_calendar.dart b/app/lib/goals/reading_activity_calendar.dart new file mode 100644 index 00000000..15358167 --- /dev/null +++ b/app/lib/goals/reading_activity_calendar.dart @@ -0,0 +1,59 @@ +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/models/reading_activity.dart'; + +class ReadingActivityDay { + const ReadingActivityDay({required this.seconds, required this.hasActivity}); + final int seconds; + final bool hasActivity; + int get level => !hasActivity + ? 0 + : seconds < 15 * 60 + ? 1 + : seconds < 30 * 60 + ? 2 + : seconds < 60 * 60 + ? 3 + : 4; +} + +/// Calendar presentation uses the same interval union and corrections as goals. +/// Consume raw checkpoints, not grouped sessions whose elapsed span includes pauses. +Map readingActivityCalendar( + Iterable ledger, { + required GoalRange range, + required String timezone, + required DateTime now, +}) { + final intervals = >{}; + final active = {}; + final limit = now.isBefore(range.end) ? now : range.end; + for (final entry in effectiveActivities(ledger)) { + final point = entry.kind == 'reading' && entry.endTime.isAfter(entry.startTime) + ? entry.endTime.subtract(const Duration(microseconds: 1)) + : entry.endTime; + if (range.contains(point) && + !point.isAfter(now) && + (entry.kind == 'completion' || entry.pages > 0 || entry.coverage.isNotEmpty)) { + active.add(GoalCalendar.midnight(point, timezone)); + } + if (entry.kind != 'reading') continue; + var cursor = entry.startTime.isAfter(range.start) ? entry.startTime : range.start; + final end = entry.endTime.isBefore(limit) ? entry.endTime : limit; + while (cursor.isBefore(end)) { + final day = GoalCalendar.midnight(cursor, timezone); + final boundary = GoalCalendar.nextDay(cursor, timezone); + final stop = boundary.isBefore(end) ? boundary : end; + intervals.putIfAbsent(day, () => []).add(( + cursor.microsecondsSinceEpoch / 1e6, + stop.microsecondsSinceEpoch / 1e6, + )); + active.add(day); + cursor = stop; + } + } + return { + for (final day in active) + day: ReadingActivityDay(seconds: unionLength(intervals[day] ?? []).floor(), hasActivity: true), + }; +} diff --git a/app/lib/pages/goals_page.dart b/app/lib/pages/goals_page.dart index d2831bc9..9826f5ab 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -4,6 +4,7 @@ import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/reading_activity.dart'; import 'package:papyrus/models/reading_goal.dart'; @@ -16,8 +17,11 @@ import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; import 'package:papyrus/widgets/goals/goal_card.dart'; +import 'package:papyrus/widgets/goals/goal_controls.dart'; import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; +import 'package:papyrus/widgets/goals/reading_activity_heatmap.dart'; +import 'package:papyrus/widgets/goals/reading_activity_timeline.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; @@ -29,11 +33,23 @@ class GoalsPage extends StatefulWidget { State createState() => _GoalsPageState(); } -class _GoalsPageState extends State { +class _GoalsPageState extends State with SingleTickerProviderStateMixin { final _provider = GoalsProvider(); - bool _history = false; + bool _activity = false; + late final TabController _tabs; + @override + void initState() { + super.initState(); + _tabs = TabController(length: 2, vsync: this, animationDuration: Duration.zero); + _tabs.addListener(() { + if (_activity != (_tabs.index == 1)) setState(() => _activity = _tabs.index == 1); + }); + } + String? _filterGoal; DateTimeRange? _filterDates; + int? _activityYear; + String _activityKind = 'all'; @override void didChangeDependencies() { super.didChangeDependencies(); @@ -42,6 +58,7 @@ class _GoalsPageState extends State { @override void dispose() { + _tabs.dispose(); _provider.dispose(); super.dispose(); } @@ -49,108 +66,140 @@ class _GoalsPageState extends State { @override Widget build(BuildContext context) => AnimatedBuilder( animation: _provider, - builder: (context, _) => Scaffold( - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final padding = constraints.maxWidth < Breakpoints.tablet ? Spacing.md : Spacing.xl; - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1200), - child: ListView( - padding: EdgeInsets.all(padding), - children: [ - LayoutBuilder( - builder: (context, box) { - final actions = Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + builder: (context, _) => GoalControls( + child: Scaffold( + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final padding = constraints.maxWidth < Breakpoints.tablet ? Spacing.md : Spacing.xl; + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: ListView( + padding: EdgeInsets.all(padding), + children: [ + LayoutBuilder( + builder: (context, box) { + final actions = Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + ), + onPressed: () => LogReadingSheet.show(context, provider: _provider), + icon: const Icon(Icons.edit_outlined), + label: const Text('Log reading'), ), - onPressed: () => LogReadingSheet.show(context, provider: _provider), - icon: const Icon(Icons.edit_outlined), - label: const Text('Log reading'), - ), - FilledButton.icon( - style: FilledButton.styleFrom( - minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + FilledButton.icon( + style: FilledButton.styleFrom( + minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), + ), + onPressed: () => AddGoalSheet.show(context, provider: _provider), + icon: const Icon(Icons.add), + label: const Text('New goal'), ), - onPressed: () => AddGoalSheet.show(context, provider: _provider), - icon: const Icon(Icons.add), - label: const Text('New goal'), - ), - ], - ); - if (box.maxWidth < 520 || MediaQuery.textScalerOf(context).scale(16) > 24) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Goals', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: Spacing.md), - actions, ], ); - } - return Row( - children: [ - Expanded(child: Text('Goals', style: Theme.of(context).textTheme.headlineMedium)), - Row( - mainAxisSize: MainAxisSize.min, + if (box.maxWidth < 520 || MediaQuery.textScalerOf(context).scale(16) > 24) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - actions.children.first, - const SizedBox(width: Spacing.sm), - actions.children.last, + Text('Goals', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: Spacing.md), + actions, ], - ), - ], - ); - }, - ), - const SizedBox(height: Spacing.lg), - Align( - alignment: AlignmentDirectional.centerStart, - child: SegmentedButton( - segments: const [ - ButtonSegment(value: false, label: Text('Overview')), - ButtonSegment(value: true, label: Text('History')), - ], - selected: {_history}, - onSelectionChanged: (selection) => setState(() => _history = selection.first), - ), - ), - const SizedBox(height: Spacing.xl), - if (_provider.isLoading) - const Center(child: AppCircularProgressIndicator()) - else if (_history) - ..._historyContent(context) - else - ..._overview(context), - if (_provider.error != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: Text(_provider.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ); + } + return Row( + children: [ + Expanded(child: Text('Goals', style: Theme.of(context).textTheme.headlineMedium)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + actions.children.first, + const SizedBox(width: Spacing.sm), + actions.children.last, + ], + ), + ], + ); + }, ), - ], + const SizedBox(height: Spacing.lg), + Align(alignment: AlignmentDirectional.centerStart, child: _viewTabs(context)), + const SizedBox(height: Spacing.xl), + if (_provider.isLoading) + const Center(child: AppCircularProgressIndicator()) + else if (_activity) + ..._activityContent(context) + else + ..._overview(context), + if (_provider.error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_provider.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ), ), ); + Widget _viewTabs(BuildContext context) { + if (MediaQuery.textScalerOf(context).scale(16) <= 24) { + return TabBar( + controller: _tabs, + isScrollable: true, + tabAlignment: TabAlignment.start, + indicatorSize: TabBarIndicatorSize.label, + dividerHeight: 1, + labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.md), + tabs: const [ + Tab(text: 'Overview'), + Tab(text: 'Activity'), + ], + ); + } + final colors = Theme.of(context).colorScheme; + return Wrap( + spacing: Spacing.sm, + children: [ + for (var index = 0; index < 2; index++) + Semantics( + selected: _tabs.index == index, + child: TextButton( + onPressed: () => _tabs.index = index, + style: TextButton.styleFrom(foregroundColor: _tabs.index == index ? colors.primary : colors.onSurface), + child: Container( + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: _tabs.index == index ? colors.primary : Colors.transparent, width: 2), + ), + ), + child: Text(index == 0 ? 'Overview' : 'Activity'), + ), + ), + ), + ], + ); + } + List _overview(BuildContext context) { final current = _provider.current; final today = current .where((value) => value.goal.period == GoalPeriod.daily || value.goal.type == GoalType.days) .toList(); final longer = current.where((value) => !today.contains(value)).toList(); - final activity = groupReadingActivities(_provider.store.effectiveReadingActivities); + final texts = Theme.of(context).textTheme; return [ if (current.isEmpty) ...[ @@ -182,7 +231,18 @@ class _GoalsPageState extends State { ), ], if (today.isNotEmpty) ...[ - Text('Today · ${DateFormat.MMMMEEEEd().format(DateTime.now())}', style: texts.titleLarge), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.md, + children: [ + Text('Today', style: texts.titleLarge), + Text( + DateFormat.MMMMEEEEd().format(DateTime.now()), + style: texts.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), const SizedBox(height: Spacing.md), _cards(context, today), const SizedBox(height: Spacing.xl), @@ -193,53 +253,58 @@ class _GoalsPageState extends State { _cards(context, longer), const SizedBox(height: Spacing.xl), ], - if (activity.isNotEmpty) ...[ - Text('Recent activity', style: texts.titleLarge), - const SizedBox(height: Spacing.sm), - for (final entry in activity.take(5)) ReadingActivityTile(activity: entry, provider: _provider), - TextButton(onPressed: () => setState(() => _history = true), child: const Text('View history')), - ], + if (_provider.store.effectiveReadingActivities.isNotEmpty) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: () => _tabs.index = 1, + icon: const Icon(Icons.history), + label: const Text('View activity'), + ), + ), ]; } Widget _cards(BuildContext context, List goals, {bool historical = false}) => LayoutBuilder( builder: (context, constraints) { final columns = constraints.maxWidth >= 760 && MediaQuery.textScalerOf(context).scale(16) <= 24 ? 2 : 1; - final width = (constraints.maxWidth - (columns - 1) * Spacing.md) / columns; - return Wrap( - spacing: Spacing.md, - runSpacing: Spacing.md, + Widget card(GoalProgress progress) => GoalCard( + key: ValueKey('goal-card-${progress.goal.id}${historical ? '-${progress.range.start.toIso8601String()}' : ''}'), + fillHeight: columns == 2, + goal: progress.goal, + progress: progress, + scopeLabel: _scopeLabel(progress.goal), + bookProgressLabel: progress.goal.scope == GoalScope.book && progress.goal.type == GoalType.books && !historical + ? '${((_provider.store.getBook(progress.goal.scopeId!)?.currentPosition ?? 0) * 100).round()}% through the book · Finish confirmation required' + : null, + onTap: () => GoalDetailsSheet.show( + context, + goal: progress.goal, + provider: _provider, + historical: historical ? progress : null, + ), + onContinue: historical ? null : () => _continueReading(progress.goal), + onMenu: historical ? null : (action) => _goalAction(progress.goal, action), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final progress in goals) - SizedBox( - width: width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - GoalCard( - goal: progress.goal, - progress: progress, - scopeLabel: _scopeLabel(progress.goal), - onTap: () => GoalDetailsSheet.show( - context, - goal: progress.goal, - provider: _provider, - historical: historical ? progress : null, - ), - onContinue: historical ? null : () => _continueReading(progress.goal), - onMenu: historical ? null : (action) => _goalAction(progress.goal, action), - ), - if (progress.goal.scope == GoalScope.book && progress.goal.type == GoalType.books && !historical) - Padding( - padding: const EdgeInsets.only(top: Spacing.sm, left: Spacing.lg), - child: Text( - '${((_provider.store.getBook(progress.goal.scopeId!)?.currentPosition ?? 0) * 100).round()}% through the book · Finish confirmation required', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], + for (var i = 0; i < goals.length; i += columns) ...[ + if (i > 0) const SizedBox(height: Spacing.md), + if (columns == 1) + card(goals[i]) + else + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: card(goals[i])), + const SizedBox(width: Spacing.md), + Expanded(child: i + 1 < goals.length ? card(goals[i + 1]) : const SizedBox()), + ], + ), ), - ), + ], ], ); }, @@ -249,39 +314,74 @@ class _GoalsPageState extends State { GoalScope.book => _provider.store.getBook(goal.scopeId!)?.title ?? 'Removed book', GoalScope.shelf => _provider.store.getShelf(goal.scopeId!)?.name ?? 'Removed shelf', }; - List _historyContent(BuildContext context) { + List _activityContent(BuildContext context) { final periods = _provider.history; final names = { for (final record in periods) record.goal.id: record.goal.displayTitle, for (final goal in _provider.store.goalDefinitions) goal.id: goal.displayTitle, }; if (!names.containsKey(_filterGoal)) _filterGoal = null; - final selectedDefinition = _filterGoal == null + final selected = _filterGoal == null ? null : _provider.store.getReadingGoal(_filterGoal!) ?? periods.where((p) => p.goal.id == _filterGoal).first.goal; - final activity = - _provider.store.readingActivities - .where( - (a) => - a.kind != 'reversal' && - (selectedDefinition == null || matchesGoal(selectedDefinition, a)) && - (_filterDates == null || - !a.endTime.isBefore(_filterDates!.start) && - a.endTime.isBefore(_filterDates!.end.copyWith(day: _filterDates!.end.day + 1))), - ) - .toList() - ..sort((a, b) => b.endTime.compareTo(a.endTime)); + final timezone = selected?.timezone ?? GoalCalendar.systemTimezone; + final now = _provider.now; + final year = _activityYear ?? GoalCalendar.local(now, timezone).year; + final yearRange = GoalCalendar.calendarPeriod(GoalPeriod.yearly, DateTime.utc(year, 1, 2), timezone); + final dateRange = _filterDates == null + ? yearRange + : GoalRange( + GoalCalendar.dayOffset(GoalCalendar.deadline(_filterDates!.start, timezone), -1, timezone), + GoalCalendar.deadline(_filterDates!.end, timezone), + ); + final range = dateRange; + final scoped = _provider.store.readingActivities + .where((a) => selected == null || matchesGoal(selected, a)) + .toList(); final corrected = _provider.store.readingActivities .where((a) => a.kind == 'reversal') .map((a) => a.correctionOf) + .whereType() .toSet(); + bool matchesKind(ReadingActivity a) => switch (_activityKind) { + 'reader' => a.source == 'reader', + 'manual' => a.source != 'reader', + 'finished' => a.kind == 'completion', + 'corrected' => corrected.contains(a.id), + _ => true, + }; + // Apply reversals before source filters: a manual correction can undo reader activity. + final effective = effectiveActivities(scoped).where((a) => _activityKind != 'corrected' && matchesKind(a)).toList(); + final visible = + (_activityKind == 'corrected' ? scoped.where((a) => a.kind != 'reversal' && matchesKind(a)) : effective) + .where( + (a) => (a.kind == 'reading' && a.endTime.isAfter(a.startTime) + ? a.endTime.isAfter(range.start) && a.startTime.isBefore(range.end) + : range.contains(a.endTime)), + ) + .toList(); + final totals = projectGoal( + ReadingGoal( + id: 'activity-summary', + type: GoalType.minutes, + targetValue: 1, + period: GoalPeriod.custom, + createdAt: range.start, + startDate: range.start, + endDate: range.end, + timezone: timezone, + isRecurring: false, + ), + effective, + now, + period: range, + ); final filtered = periods .where( (p) => (_filterGoal == null || p.goal.id == _filterGoal) && - (_filterDates == null || - p.range.start.isBefore(_filterDates!.end.copyWith(day: _filterDates!.end.day + 1)) && - p.range.end.isAfter(_filterDates!.start)), + p.range.start.isBefore(range.end) && + p.range.end.isAfter(range.start), ) .toList(); final archived = _provider.store.goalDefinitions @@ -289,20 +389,58 @@ class _GoalsPageState extends State { .map(_provider.progress) .toList(); return [ + ReadingActivityHeatmap( + activities: effective, + year: year, + timezone: timezone, + now: now, + selectedDay: _filterDates?.start == _filterDates?.end && _filterDates != null + ? GoalCalendar.dayOffset(GoalCalendar.deadline(_filterDates!.start, timezone), -1, timezone) + : null, + onYearChanged: (value) => setState(() { + _activityYear = value; + _filterDates = null; + }), + onDaySelected: (day) => setState(() { + final local = GoalCalendar.local(day, timezone); + final civil = DateTime(local.year, local.month, local.day); + _filterDates = DateTimeRange(start: civil, end: civil); + }), + ), + const SizedBox(height: Spacing.lg), Wrap( - spacing: Spacing.md, - runSpacing: Spacing.sm, + spacing: Spacing.sm, + runSpacing: Spacing.md, crossAxisAlignment: WrapCrossAlignment.center, children: [ SizedBox( - width: (MediaQuery.sizeOf(context).width - Spacing.xl * 2).clamp(120, 300).toDouble(), + width: (MediaQuery.sizeOf(context).width - Spacing.xl * 2).clamp(120, 220).toDouble(), + child: DropdownButtonFormField( + initialValue: _activityKind, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Activity'), + items: [ + for (final value in { + 'all': 'All activity', + 'reader': 'Reader', + 'manual': 'Manual', + 'finished': 'Finished books', + 'corrected': 'Corrected entries', + }.entries) + DropdownMenuItem(value: value.key, child: Text(value.value)), + ], + onChanged: (value) => setState(() => _activityKind = value!), + ), + ), + SizedBox( + width: (MediaQuery.sizeOf(context).width - Spacing.xl * 2).clamp(120, 280).toDouble(), child: DropdownButtonFormField( key: ValueKey(_filterGoal), initialValue: _filterGoal ?? '', isExpanded: true, - decoration: const InputDecoration(labelText: 'Goal'), + decoration: const InputDecoration(labelText: 'Goal scope'), items: [ - const DropdownMenuItem(value: '', child: Text('All goals')), + const DropdownMenuItem(value: '', child: Text('Whole library')), for (final name in names.entries) DropdownMenuItem( value: name.key, @@ -320,12 +458,19 @@ class _GoalsPageState extends State { lastDate: DateTime.now(), initialDateRange: _filterDates, ); - if (dates != null && mounted) setState(() => _filterDates = dates); + if (dates != null && mounted) { + setState(() { + _filterDates = dates; + _activityYear = dates.end.year; + }); + } }, icon: const Icon(Icons.date_range_outlined), label: Text( _filterDates == null ? 'Filter dates' + : _filterDates!.start == _filterDates!.end + ? DateFormat.MMMd().format(_filterDates!.start) : '${DateFormat.MMMd().format(_filterDates!.start)} – ${DateFormat.MMMd().format(_filterDates!.end)}', ), ), @@ -333,37 +478,85 @@ class _GoalsPageState extends State { TextButton(onPressed: () => setState(() => _filterDates = null), child: const Text('Clear dates')), ], ), - const SizedBox(height: Spacing.xl), - if (filtered.isNotEmpty) ...[ - Text('Previous periods', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: Spacing.md), - _cards(context, filtered, historical: true), - const SizedBox(height: Spacing.xl), - ], - if (archived.isNotEmpty) ...[ - Text('Archived goals', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: Spacing.md), - _cards(context, archived, historical: true), - const SizedBox(height: Spacing.xl), - ], - Text('Reading activity', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: Spacing.sm), - if (activity.isEmpty) - const EmptyState.compact( - icon: Icons.history_outlined, - title: 'No reading activity', - subtitle: 'Reading and manual logs will appear here.', - alignment: Alignment.topCenter, + const SizedBox(height: Spacing.lg), + if (_activityKind != 'corrected') + Container( + padding: const EdgeInsets.all(Spacing.md), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Wrap( + spacing: Spacing.xl, + runSpacing: Spacing.md, + children: [ + _activityTotal(context, '${totals.finishedBooks}', 'books finished'), + _activityTotal( + context, + '${totals.pages.floor()}', + totals.estimated ? 'pages (includes estimates)' : 'pages read', + ), + _activityTotal( + context, + totals.seconds < 60 ? '${totals.seconds} sec' : formatDuration(totals.seconds ~/ 60), + 'reading time', + ), + ], + ), + ), + if (visible.isEmpty) + const Padding( + padding: EdgeInsets.only(top: Spacing.lg), + child: EmptyState.compact( + icon: Icons.history_outlined, + title: 'No reading activity', + subtitle: 'Reading and manual logs will appear here.', + alignment: Alignment.topCenter, + ), ) else - for (final entry in [ - ...groupReadingActivities(activity.where((entry) => !corrected.contains(entry.id))), - ...groupReadingActivities(activity.where((entry) => corrected.contains(entry.id))), - ]) - ReadingActivityTile(activity: entry, provider: _provider, corrected: corrected.contains(entry.id)), + ReadingActivityTimeline( + activities: visible, + provider: _provider, + timezone: timezone, + correctedIds: corrected, + range: range, + ), + const SizedBox(height: Spacing.lg), + if (filtered.isNotEmpty || archived.isNotEmpty) + ExpansionTile( + key: const Key('goal-period-history'), + tilePadding: EdgeInsets.zero, + title: const Text('Goal history'), + subtitle: const Text('Past periods and archived goals'), + children: [ + if (filtered.isNotEmpty) ...[ + const SizedBox(height: Spacing.md), + _cards(context, filtered, historical: true), + ], + if (archived.isNotEmpty) ...[ + const SizedBox(height: Spacing.lg), + Align( + alignment: AlignmentDirectional.centerStart, + child: Text('Archived goals', style: Theme.of(context).textTheme.titleMedium), + ), + const SizedBox(height: Spacing.md), + _cards(context, archived, historical: true), + ], + ], + ), ]; } + Widget _activityTotal(BuildContext context, String value, String label) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.xs), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + Future _goalAction(ReadingGoal goal, String action) async { if (action == 'edit') { await AddGoalSheet.show(context, provider: _provider, editing: _provider.store.getReadingGoal(goal.id)); diff --git a/app/lib/providers/goals_provider.dart b/app/lib/providers/goals_provider.dart index e607cd42..f0a917ea 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -30,7 +30,11 @@ class GoalsProvider extends ChangeNotifier { _store = dataStore; dataStore.addListener(_changed); if (watchClock) _clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _changed()); - _changed(); + // Attaching from a page's dependency update must not persist periods and + // notify the DataStore's listeners while that page is still building. + scheduleMicrotask(() { + if (identical(_store, dataStore)) _changed(); + }); } void _changed() { @@ -344,6 +348,7 @@ class GoalsProvider extends ChangeNotifier { void dispose() { _clock?.cancel(); _store?.removeListener(_changed); + _store = null; super.dispose(); } } diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index 97689cd6..4eedec4b 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -1,24 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:papyrus/widgets/goals/goal_card.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; import 'package:papyrus/data/repositories/tracking_repository.dart'; import 'package:papyrus/goals/goal_calendar.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/goals/goal_controls.dart'; +import 'package:papyrus/widgets/goals/goal_timezone_picker.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; class AddGoalSheet extends StatefulWidget { - const AddGoalSheet({super.key, required this.provider, this.editing, this.preset = 0, this.initialTimezone}); + const AddGoalSheet({super.key, required this.provider, this.editing, this.preset, this.initialTimezone}); final GoalsProvider provider; final ReadingGoal? editing; - final int preset; + final int? preset; final String? initialTimezone; static Future show( BuildContext context, { required GoalsProvider provider, ReadingGoal? editing, - int preset = 0, + int? preset, String? initialTimezone, }) => showModalBottomSheet( context: context, @@ -46,16 +51,17 @@ class _AddGoalSheetState extends State { GoalScope _scope = GoalScope.library; String? _scopeId; bool _recurring = true; + bool _choosingMetric = false; bool _saving = false; bool _timezoneReady = false; String? _error; - int _preset = 0; DateTime _deadline = DateTime.now().add(const Duration(days: 30)); @override void initState() { super.initState(); _repository = widget.provider.store.trackingRepository; - _applyPreset(widget.preset); + _choosingMetric = widget.editing == null && widget.preset == null; + _applyPreset(widget.preset ?? 0); final editing = widget.editing; if (editing != null) { _type = editing.type; @@ -86,7 +92,8 @@ class _AddGoalSheetState extends State { if (mounted) { setState(() { _timezoneReady = true; - _error = 'Could not detect your timezone. Check the timezone below before saving.'; + _error = + 'Could not detect your timezone. Using UTC; choose another timezone in advanced settings if needed.'; }); } }); @@ -94,7 +101,6 @@ class _AddGoalSheetState extends State { } void _applyPreset(int index) { - _preset = index; _type = [GoalType.minutes, GoalType.days, GoalType.books, GoalType.pages, GoalType.books][index]; _period = [GoalPeriod.daily, GoalPeriod.weekly, GoalPeriod.yearly, GoalPeriod.weekly, GoalPeriod.custom][index]; _target.text = ['30', '5', '12', '100', '1'][index]; @@ -117,9 +123,72 @@ class _AddGoalSheetState extends State { return number == null || number < 1 || number > 100000 ? 'Enter a number from 1 to 100,000.' : null; } + String _metricLabel(GoalType type) => switch (type) { + GoalType.books => 'Books finished', + GoalType.pages => 'Pages read', + GoalType.minutes => 'Reading time', + GoalType.days => 'Reading days', + }; + + void _chooseMetric(GoalType type) => setState(() { + _applyPreset({GoalType.books: 2, GoalType.pages: 3, GoalType.minutes: 0, GoalType.days: 1}[type]!); + _choosingMetric = false; + }); + + void _adjustTarget(int delta) => setState(() { + final value = ((int.tryParse(_target.text) ?? 0) + delta).clamp(1, 100000); + _target.text = '$value'; + _target.selection = TextSelection.collapsed(offset: _target.text.length); + }); + @override Widget build(BuildContext context) { final editing = widget.editing != null; + final texts = Theme.of(context).textTheme; + final colors = Theme.of(context).colorScheme; + if (_choosingMetric) { + return GoalControls( + child: AppBottomSheet( + title: 'New goal', + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('What would you like to track?', style: texts.titleLarge), + const SizedBox(height: Spacing.lg), + for (final type in GoalType.values) + Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Material( + color: colors.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + side: BorderSide(color: colors.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + leading: Icon(goalIcon(type), color: colors.primary), + title: Text(_metricLabel(type)), + subtitle: Text(switch (type) { + GoalType.books => 'Finish books from your library', + GoalType.pages => 'Read a set number of pages', + GoalType.minutes => 'Make time for reading', + GoalType.days => 'Read regularly throughout the week', + }), + trailing: const Icon(Icons.chevron_right), + onTap: () => _chooseMetric(type), + ), + ), + ), + ], + ), + footer: BottomSheetActions( + primary: OutlinedButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + ), + ), + ); + } final store = widget.provider.store; final preview = ReadingGoal( id: 'preview', @@ -134,248 +203,278 @@ class _AddGoalSheetState extends State { final items = _scope == GoalScope.book ? {for (final book in store.books) book.id: book.title} : {for (final shelf in store.shelves) shelf.id: shelf.name}; - return AppBottomSheet( - title: editing ? 'Edit goal' : 'New goal', - canClose: !_saving, - onClose: () => Navigator.pop(context), - body: Form( - key: _form, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!editing) ...[ + return GoalControls( + child: AppBottomSheet( + title: editing ? 'Edit goal' : 'New goal', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _type, + key: ValueKey('metric-$_type'), + isExpanded: true, + decoration: const InputDecoration(labelText: 'Measure'), + items: [ + for (final type in GoalType.values) DropdownMenuItem(value: type, child: Text(_metricLabel(type))), + ], + onChanged: _saving + ? null + : (value) => setState(() { + _type = value!; + }), + ), + const SizedBox(height: Spacing.lg), + Align( + alignment: AlignmentDirectional.centerStart, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Row( + children: [ + IconButton.outlined( + tooltip: 'Decrease target', + onPressed: _saving ? null : () => _adjustTarget(-1), + icon: const Icon(Icons.remove), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: TextFormField( + key: const Key('goal-target-input'), + controller: _target, + enabled: !_saving, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: texts.headlineSmall, + decoration: const InputDecoration(labelText: 'Target'), + validator: _positive, + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: Spacing.sm), + IconButton.outlined( + tooltip: 'Increase target', + onPressed: _saving ? null : () => _adjustTarget(1), + icon: const Icon(Icons.add), + ), + ], + ), + ), + ), + const SizedBox(height: Spacing.sm), + Text(preview.typeLabel, style: texts.bodyMedium), + const SizedBox(height: Spacing.lg), + Text('Schedule', style: texts.titleSmall), + const SizedBox(height: Spacing.sm), Wrap( spacing: Spacing.sm, runSpacing: Spacing.sm, children: [ - for (var i = 0; i < 5; i++) + for (final period in GoalPeriod.values) ChoiceChip( - label: Text( - ['Daily time', 'Reading days', 'Books this year', 'Weekly pages', 'Finish a book'][i], - ), - selected: _preset == i, - onSelected: _saving ? null : (_) => setState(() => _applyPreset(i)), + label: Text(switch (period) { + GoalPeriod.daily => 'Daily', + GoalPeriod.weekly => 'Weekly', + GoalPeriod.monthly => 'Monthly', + GoalPeriod.yearly => 'Yearly', + GoalPeriod.custom => 'By date', + }), + selected: _period == period, + showCheckmark: false, + onSelected: _saving + ? null + : (_) => setState(() { + _period = period; + if (period == GoalPeriod.custom) _recurring = false; + }), ), ], ), - const SizedBox(height: Spacing.lg), - ], - TextFormField( - controller: _title, - maxLength: 255, - decoration: const InputDecoration(labelText: 'Name (optional)'), - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: Spacing.md), - DropdownButtonFormField( - initialValue: _type, - key: ValueKey('metric-$_type'), - isExpanded: true, - decoration: const InputDecoration(labelText: 'Measure'), - items: [ - for (final type in GoalType.values) - DropdownMenuItem( - value: type, - child: Text( - { - GoalType.books: 'Books finished', - GoalType.pages: 'Pages read', - GoalType.minutes: 'Reading time (minutes)', - GoalType.days: 'Reading days', - }[type]!, - ), - ), - ], - onChanged: _saving - ? null - : (value) => setState(() { - _type = value!; - _preset = -1; - }), - ), - const SizedBox(height: Spacing.md), - TextFormField( - controller: _target, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: 'Target (${preview.typeLabel})'), - validator: _positive, - onChanged: (_) => setState(() {}), - ), - const SizedBox(height: Spacing.md), - DropdownButtonFormField( - initialValue: _period, - key: ValueKey('period-$_period'), - isExpanded: true, - decoration: const InputDecoration(labelText: 'Schedule'), - items: [ - for (final period in GoalPeriod.values) - DropdownMenuItem( - value: period, - child: Text( - { - GoalPeriod.daily: 'Daily', - GoalPeriod.weekly: 'Weekly', - GoalPeriod.monthly: 'Monthly', - GoalPeriod.yearly: 'Yearly', - GoalPeriod.custom: 'By a deadline', - }[period]!, + if (_period == GoalPeriod.custom) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final date = await showAppDatePicker( + context: context, + initialDate: _deadline.isBefore(DateTime.now()) ? DateTime.now() : _deadline, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 3650)), + ); + if (date != null && mounted) setState(() => _deadline = date); + }, + icon: const Icon(Icons.event_outlined), + label: Text('Deadline: ${_deadline.day}/${_deadline.month}/${_deadline.year}'), ), ), - ], - onChanged: _saving - ? null - : (value) => setState(() { - _period = value!; - if (_period == GoalPeriod.custom) _recurring = false; - }), - ), - if (_period == GoalPeriod.custom) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: OutlinedButton.icon( - onPressed: _saving - ? null - : () async { - final date = await showAppDatePicker( - context: context, - initialDate: _deadline.isBefore(DateTime.now()) ? DateTime.now() : _deadline, - firstDate: DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (date != null && mounted) setState(() => _deadline = date); - }, - icon: const Icon(Icons.event_outlined), - label: Text('Deadline: ${_deadline.day}/${_deadline.month}/${_deadline.year}'), ), - ) - else - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Repeat each period'), - value: _recurring, - onChanged: _saving ? null : (value) => setState(() => _recurring = value), + const SizedBox(height: Spacing.lg), + TextFormField( + controller: _title, + enabled: !_saving, + maxLength: 255, + decoration: const InputDecoration(labelText: 'Name (optional)', counterText: ''), + onChanged: (_) => setState(() {}), ), - const SizedBox(height: Spacing.md), - DropdownButtonFormField( - initialValue: _scope, - key: ValueKey('scope-$_scope'), - isExpanded: true, - decoration: const InputDecoration(labelText: 'Include'), - items: [ - for (final scope in GoalScope.values) - DropdownMenuItem( - value: scope, - child: Text( - { - GoalScope.library: 'Whole library', - GoalScope.book: 'A book', - GoalScope.shelf: 'A shelf', - }[scope]!, - ), + if (_scope == GoalScope.book) ...[const SizedBox(height: Spacing.md), _scopeItem(items)], + const SizedBox(height: Spacing.sm), + ExpansionTile( + key: const Key('goal-advanced-settings'), + maintainState: true, + tilePadding: EdgeInsets.zero, + title: const Text('Advanced settings'), + children: [ + DropdownButtonFormField( + initialValue: _scope, + key: ValueKey('scope-$_scope'), + isExpanded: true, + decoration: const InputDecoration(labelText: 'Include'), + items: [ + for (final scope in GoalScope.values) + DropdownMenuItem( + value: scope, + child: Text(switch (scope) { + GoalScope.library => 'Whole library', + GoalScope.book => 'A book', + GoalScope.shelf => 'A shelf', + }), + ), + ], + onChanged: _saving + ? null + : (value) => setState(() { + _scope = value!; + _scopeId = null; + }), ), - ], - onChanged: _saving - ? null - : (value) => setState(() { - _scope = value!; - _scopeId = null; - }), - ), - if (_scope != GoalScope.library) ...[ - const SizedBox(height: Spacing.md), - DropdownButtonFormField( - initialValue: items.containsKey(_scopeId) ? _scopeId : null, - key: ValueKey('scope-item-$_scope-$_scopeId'), - isExpanded: true, - decoration: InputDecoration(labelText: _scope == GoalScope.book ? 'Book' : 'Shelf'), - items: [ - for (final item in items.entries) - DropdownMenuItem( - value: item.key, - child: Text(item.value, overflow: TextOverflow.ellipsis), + if (_scope == GoalScope.shelf) ...[const SizedBox(height: Spacing.md), _scopeItem(items)], + if (_period != GoalPeriod.custom) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Repeat each period'), + value: _recurring, + onChanged: _saving ? null : (value) => setState(() => _recurring = value), + ), + if (_type == GoalType.days) + Padding( + padding: const EdgeInsets.symmetric(vertical: Spacing.md), + child: _compact( + width: 320, + child: TextFormField( + controller: _threshold, + enabled: !_saving, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Minutes to qualify a day', + helperText: 'Time adds up throughout the day.', + ), + validator: (value) { + final n = int.tryParse(value ?? ''); + return n == null || n < 1 || n > 1440 ? 'Enter 1–1,440 minutes.' : null; + }, + onChanged: (_) => setState(() {}), + ), + ), ), + const SizedBox(height: Spacing.md), + Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + key: const Key('goal-timezone-button'), + onPressed: _saving + ? null + : () async { + final zone = await GoalTimezonePicker.show(context, selected: _zone.text); + if (zone != null && mounted) setState(() => _zone.text = zone); + }, + icon: const Icon(Icons.public_outlined), + label: Text('Timezone: ${timezoneCity(_zone.text)}'), + ), + ), + const SizedBox(height: Spacing.sm), + const Text('Defaults to your device. Calendar periods use this timezone across your devices.'), + const SizedBox(height: Spacing.md), ], - validator: (value) => - value == null ? 'Choose ${_scope == GoalScope.book ? 'a book' : 'a shelf'}.' : null, - onChanged: _saving ? null : (value) => setState(() => _scopeId = value), ), - ], - if (_type == GoalType.days) ...[ const SizedBox(height: Spacing.md), - TextFormField( - controller: _threshold, - enabled: !_saving, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Minimum minutes per reading day', - helperText: 'Time adds up throughout the day.', - ), - validator: (value) { - final n = int.tryParse(value ?? ''); - return n == null || n < 1 || n > 1440 ? 'Enter 1–1,440 minutes.' : null; - }, - onChanged: (_) => setState(() {}), + Text( + '${preview.description}. ${_scope == GoalScope.library ? 'Across your library' : items[_scopeId] ?? 'Choose a book or shelf'}.', + style: texts.bodyLarge, ), - ], - const SizedBox(height: Spacing.md), - TextFormField( - controller: _zone, - enabled: !_saving, - decoration: const InputDecoration( - labelText: 'Timezone', - helperText: 'Calendar periods follow this timezone on every device.', - ), - onChanged: (_) => setState(() {}), - validator: (value) { - try { - GoalCalendar.location(value ?? ''); - return null; - } catch (_) { - return 'Use an IANA timezone, such as Europe/Vilnius.'; - } - }, - ), - const SizedBox(height: Spacing.lg), - Text('Preview', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: Spacing.sm), - Text( - '${preview.description}. ${_scope == GoalScope.library ? 'Across your library' : items[_scopeId] ?? 'Choose a book or shelf'}.', - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: Spacing.sm), - Text( - editing - ? (_replacementNeeded - ? 'This change starts a replacement goal. The current goal is archived with its history; only new reading counts toward the replacement.' - : 'Past periods keep their original target.') - : 'Only reading after this goal is created counts. Progress comes from the reader and your manual logs.', - style: Theme.of(context).textTheme.bodyMedium, - ), - if (_type == GoalType.pages) - const Padding( - padding: EdgeInsets.only(top: Spacing.sm), - child: Text('PDF pages are measured. EPUB pages are estimated when page-count metadata is available.'), + const SizedBox(height: Spacing.sm), + Text( + editing + ? (_replacementNeeded + ? 'This starts a replacement goal and preserves the original history. Only new reading counts.' + : 'Past periods keep their original target.') + : 'Progress updates from reading and manual logs after you create the goal.', + style: texts.bodySmall, ), - if (_error != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ), - ], + if (_type == GoalType.days) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text('${_threshold.text} minutes qualifies a reading day.', style: texts.bodySmall), + ), + if (_type == GoalType.pages) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text( + 'PDF pages are measured. EPUB pages are estimated when page metadata is available.', + style: texts.bodySmall, + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_error!, style: TextStyle(color: colors.error)), + ), + ], + ), + ), + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context), + onSave: _saving || !_timezoneReady ? null : _save, + saveLabel: _saving + ? 'Saving…' + : editing + ? (_replacementNeeded ? 'Replace goal' : 'Save') + : 'Create goal', ), - ), - footer: BottomSheetFormActions( - onCancel: _saving ? null : () => Navigator.pop(context), - onSave: _saving || !_timezoneReady ? null : _save, - saveLabel: _saving - ? 'Saving…' - : editing - ? (_replacementNeeded ? 'Replace goal' : 'Save') - : 'Create goal', ), ); } + Widget _scopeItem(Map items) => DropdownButtonFormField( + initialValue: items.containsKey(_scopeId) ? _scopeId : null, + key: ValueKey('scope-item-$_scope-$_scopeId'), + isExpanded: true, + decoration: InputDecoration(labelText: _scope == GoalScope.book ? 'Book' : 'Shelf'), + items: [ + for (final item in items.entries) + DropdownMenuItem( + value: item.key, + child: Text(item.value, overflow: TextOverflow.ellipsis), + ), + ], + validator: (value) => value == null ? 'Choose ${_scope == GoalScope.book ? 'a book' : 'a shelf'}.' : null, + onChanged: _saving ? null : (value) => setState(() => _scopeId = value), + ); + + Widget _compact({required Widget child, double width = 220}) => Align( + alignment: AlignmentDirectional.centerStart, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: width), + child: child, + ), + ); + bool get _replacementNeeded { final goal = widget.editing; if (goal == null) return false; diff --git a/app/lib/widgets/goals/goal_card.dart b/app/lib/widgets/goals/goal_card.dart index dfaff635..c633230f 100644 --- a/app/lib/widgets/goals/goal_card.dart +++ b/app/lib/widgets/goals/goal_card.dart @@ -24,6 +24,9 @@ class GoalCard extends StatelessWidget { this.onMenu, this.scopeLabel, this.isDesktop = false, + this.fillHeight = false, + this.bookProgressLabel, + this.showReadingDays = false, }); final ReadingGoal goal; final GoalProgress? progress; @@ -32,6 +35,9 @@ class GoalCard extends StatelessWidget { final void Function(String)? onMenu; final String? scopeLabel; final bool isDesktop; + final bool fillHeight; + final String? bookProgressLabel; + final bool showReadingDays; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -51,6 +57,7 @@ class GoalCard extends StatelessWidget { ? 'Target reached' : null; return Card( + semanticContainer: false, margin: EdgeInsets.zero, elevation: 0, color: colors.surfaceContainerLow, @@ -65,20 +72,38 @@ class GoalCard extends StatelessWidget { padding: const EdgeInsets.all(Spacing.lg), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, + mainAxisSize: fillHeight ? MainAxisSize.max : MainAxisSize.min, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(goalIcon(goal.type), color: colors.primary), + Container( + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Icon(goalIcon(goal.type), color: colors.onPrimaryContainer, size: 22), + ), const SizedBox(width: Spacing.sm), - Expanded(child: Text(projected.displayTitle, style: texts.titleMedium)), + Expanded( + child: Tooltip( + message: projected.displayTitle, + excludeFromSemantics: true, + child: Text( + projected.displayTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: texts.titleMedium, + ), + ), + ), if (onMenu != null) PopupMenuButton( tooltip: 'Goal actions', onSelected: onMenu, itemBuilder: (_) => [ - const PopupMenuItem(value: 'edit', child: Text('Edit target')), + const PopupMenuItem(value: 'edit', child: Text('Edit goal')), PopupMenuItem(value: 'pause', child: Text(goal.isActive ? 'Pause' : 'Resume')), const PopupMenuItem(value: 'archive', child: Text('Archive')), const PopupMenuItem(value: 'delete', child: Text('Delete')), @@ -86,13 +111,21 @@ class GoalCard extends StatelessWidget { ), ], ), - const SizedBox(height: Spacing.lg), + const SizedBox(height: Spacing.md), Text( '${projected.currentValue} / ${projected.targetValue} ${projected.typeLabel}', - style: texts.titleLarge, + style: texts.titleMedium, ), - const SizedBox(height: Spacing.sm), + const SizedBox(height: Spacing.xs), + Text( + goal.isRecurring + ? projected.periodLabel + : 'Until ${DateFormat.yMMMd().format(GoalCalendar.local(projected.endDate.subtract(const Duration(microseconds: 1)), goal.timezone))}', + style: texts.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + const SizedBox(height: Spacing.md), Semantics( + container: true, label: '${projected.displayTitle}, ${projected.currentValue} of ${projected.targetValue} ${projected.typeLabel}', child: LinearProgressIndicator( @@ -102,29 +135,54 @@ class GoalCard extends StatelessWidget { ), ), const SizedBox(height: Spacing.md), - if (state != null) - Row( - children: [ - Icon( - projected.isCompleted - ? Icons.check_circle_outline - : goal.isActive - ? Icons.event_outlined - : Icons.pause_circle_outline, - size: 18, + Wrap( + alignment: WrapAlignment.spaceBetween, + spacing: Spacing.sm, + runSpacing: Spacing.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (state != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + projected.isCompleted + ? Icons.check_circle_outline + : goal.isActive + ? Icons.event_outlined + : Icons.pause_circle_outline, + size: 16, + ), + const SizedBox(width: Spacing.xs), + Flexible(child: Text(state, style: texts.bodySmall)), + ], + ) + else + Text( + '${((progress?.fraction ?? projected.progress) * 100).round()}% complete', + style: texts.bodySmall, ), - const SizedBox(width: Spacing.sm), - Expanded(child: Text(state, style: texts.bodyMedium)), - ], - ) - else - Text(projected.statusText, style: texts.bodyMedium), + if (!projected.isCompleted) Text(projected.statusText, style: texts.bodySmall), + ], + ), const SizedBox(height: Spacing.xs), - Text( - '${scopeLabel ?? 'Whole library'} · ${goal.isRecurring ? projected.periodLabel : 'Until ${DateFormat.yMMMd().format(GoalCalendar.local(projected.endDate.subtract(const Duration(microseconds: 1)), goal.timezone))}'}${progress?.estimated == true && goal.type == GoalType.pages ? ' · Estimated pages included' : ''}', - style: texts.bodySmall?.copyWith(color: colors.onSurfaceVariant), + Tooltip( + message: scopeLabel ?? 'Whole library', + excludeFromSemantics: true, + child: Text( + scopeLabel ?? 'Whole library', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: texts.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), ), - if (goal.type == GoalType.days) ...[ + if (progress?.estimated == true && goal.type == GoalType.pages) + Text('Estimated pages included', style: texts.bodySmall), + if (bookProgressLabel != null) ...[ + const SizedBox(height: Spacing.sm), + Text(bookProgressLabel!, style: texts.bodySmall), + ], + if (showReadingDays && goal.type == GoalType.days) ...[ const SizedBox(height: Spacing.md), if (goal.period == GoalPeriod.weekly && progress != null) Wrap( @@ -135,9 +193,10 @@ class GoalCard extends StatelessWidget { const SizedBox(height: Spacing.sm), Text('${goal.minimumMinutes} minutes qualifies a day', style: texts.bodySmall), ], + if (fillHeight) const Spacer(), if (onContinue != null && !expired && !goal.isArchived && goal.isActive) Padding( - padding: const EdgeInsets.only(top: Spacing.md), + padding: const EdgeInsets.only(top: Spacing.sm), child: Align( alignment: AlignmentDirectional.centerStart, child: TextButton.icon( diff --git a/app/lib/widgets/goals/goal_controls.dart b/app/lib/widgets/goals/goal_controls.dart new file mode 100644 index 00000000..722dd6b4 --- /dev/null +++ b/app/lib/widgets/goals/goal_controls.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +/// Content-sized controls for Goals, including sheets opened on the root navigator. +class GoalControls extends StatelessWidget { + const GoalControls({super.key, required this.child}); + final Widget child; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const compact = ButtonStyle( + minimumSize: WidgetStatePropertyAll(Size(0, ComponentSizes.buttonHeightMobile)), + shape: WidgetStatePropertyAll(StadiumBorder()), + ); + return Theme( + data: theme.copyWith( + outlinedButtonTheme: OutlinedButtonThemeData(style: compact.merge(theme.outlinedButtonTheme.style)), + filledButtonTheme: FilledButtonThemeData(style: compact.merge(theme.filledButtonTheme.style)), + textButtonTheme: TextButtonThemeData( + style: const ButtonStyle( + shape: WidgetStatePropertyAll(StadiumBorder()), + minimumSize: WidgetStatePropertyAll(Size(0, 40)), + ).merge(theme.textButtonTheme.style), + ), + ), + child: child, + ); + } +} diff --git a/app/lib/widgets/goals/goal_details_sheet.dart b/app/lib/widgets/goals/goal_details_sheet.dart index 93b37a80..c27280cf 100644 --- a/app/lib/widgets/goals/goal_details_sheet.dart +++ b/app/lib/widgets/goals/goal_details_sheet.dart @@ -1,17 +1,18 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:papyrus/data/repositories/tracking_repository.dart'; -import 'package:papyrus/goals/goal_progress.dart'; import 'package:papyrus/goals/goal_calendar.dart'; -import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/goals/goal_progress.dart'; import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; import 'package:papyrus/widgets/goals/goal_card.dart'; +import 'package:papyrus/widgets/goals/goal_controls.dart'; import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; class GoalDetailsSheet extends StatefulWidget { const GoalDetailsSheet({super.key, required this.goal, required this.provider, this.historical}); @@ -55,87 +56,104 @@ class _GoalDetailsSheetState extends State { period: history.range, ); final periods = widget.provider.history.where((period) => period.goal.id == goal.id).toList(); - return AppBottomSheet( - title: 'Goal details', - canClose: !_saving, - onClose: () => Navigator.pop(context), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - GoalCard(goal: goal, progress: progress), - const SizedBox(height: Spacing.lg), - Text('Calendar timezone: ${goal.timezone}', style: Theme.of(context).textTheme.bodySmall), - if (widget.historical == null && !goal.isArchived) ...[ - const SizedBox(height: Spacing.md), - Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - OutlinedButton.icon( + return GoalControls( + child: AppBottomSheet( + title: 'Goal details', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GoalCard(goal: goal, progress: progress, showReadingDays: true), + const SizedBox(height: Spacing.lg), + if (widget.historical == null && !goal.isArchived) ...[ + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + onPressed: _saving + ? null + : () => AddGoalSheet.show(context, provider: widget.provider, editing: goal), + icon: const Icon(Icons.edit_outlined), + label: const Text('Edit goal'), + ), + OutlinedButton.icon( + onPressed: _saving + ? null + : () => _action( + () => widget.provider.pauseGoal(goal.id, goal.isActive, repository: _repository), + ), + icon: Icon(goal.isActive ? Icons.pause : Icons.play_arrow), + label: Text(goal.isActive ? 'Pause goal' : 'Resume goal'), + ), + OutlinedButton.icon( + onPressed: _saving + ? null + : () => _action(() => widget.provider.archiveGoal(goal.id, repository: _repository)), + icon: const Icon(Icons.archive_outlined), + label: const Text('Archive goal'), + ), + ], + ), + ], + if (goal.isArchived && widget.provider.store.getReadingGoal(goal.id) != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( onPressed: _saving ? null - : () => AddGoalSheet.show(context, provider: widget.provider, editing: goal), - icon: const Icon(Icons.edit_outlined), - label: const Text('Edit target'), + : () => _action(() => widget.provider.restoreGoal(goal.id, repository: _repository)), + icon: const Icon(Icons.unarchive_outlined), + label: const Text('Restore goal'), ), - OutlinedButton.icon( - onPressed: _saving - ? null - : () => - _action(() => widget.provider.pauseGoal(goal.id, goal.isActive, repository: _repository)), - icon: Icon(goal.isActive ? Icons.pause : Icons.play_arrow), - label: Text(goal.isActive ? 'Pause goal' : 'Resume goal'), + ), + const SizedBox(height: Spacing.lg), + Text('Counted activity', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (progress.activities.isEmpty) + const Text('No qualifying reading in this period yet.') + else + ReadingActivityList(activities: groupReadingActivities(progress.activities), provider: widget.provider), + if (periods.isNotEmpty && widget.historical == null) ...[ + const SizedBox(height: Spacing.lg), + Text('Previous periods', style: Theme.of(context).textTheme.titleMedium), + for (final period in periods) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(period.reached ? Icons.check_circle_outline : Icons.event_outlined), + title: Text( + '${DateFormat.yMMMd().format(GoalCalendar.local(period.range.start, goal.timezone))} – ${DateFormat.yMMMd().format(GoalCalendar.local(period.range.end.subtract(const Duration(microseconds: 1)), goal.timezone))}', + ), + subtitle: Text('${goalCount(period)} · ${period.reached ? 'Achieved' : 'Missed'}'), + onTap: () => GoalDetailsSheet.show( + context, + goal: period.goal, + provider: widget.provider, + historical: period, + ), ), - OutlinedButton.icon( - onPressed: _saving - ? null - : () => _action(() => widget.provider.archiveGoal(goal.id, repository: _repository)), - icon: const Icon(Icons.archive_outlined), - label: const Text('Archive goal'), + ], + ExpansionTile( + tilePadding: EdgeInsets.zero, + title: const Text('Calendar settings'), + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Text('Timezone: ${goal.timezone}', style: Theme.of(context).textTheme.bodySmall), ), ], ), + if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], - if (goal.isArchived && widget.provider.store.getReadingGoal(goal.id) != null) - TextButton.icon( - onPressed: _saving - ? null - : () => _action(() => widget.provider.restoreGoal(goal.id, repository: _repository)), - icon: const Icon(Icons.unarchive_outlined), - label: const Text('Restore goal'), - ), - const SizedBox(height: Spacing.lg), - Text('Counted activity', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: Spacing.sm), - if (progress.activities.isEmpty) - const Text('No qualifying reading in this period yet.') - else - ...groupReadingActivities( - progress.activities, - ).map((activity) => ReadingActivityTile(activity: activity, provider: widget.provider)), - if (periods.isNotEmpty && widget.historical == null) ...[ - const SizedBox(height: Spacing.lg), - Text('Previous periods', style: Theme.of(context).textTheme.titleMedium), - for (final period in periods) - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon(period.reached ? Icons.check_circle_outline : Icons.event_outlined), - title: Text( - '${DateFormat.yMMMd().format(GoalCalendar.local(period.range.start, goal.timezone))} – ${DateFormat.yMMMd().format(GoalCalendar.local(period.range.end.subtract(const Duration(microseconds: 1)), goal.timezone))}', - ), - subtitle: Text('${goalCount(period)} · ${period.reached ? 'Achieved' : 'Missed'}'), - onTap: () => - GoalDetailsSheet.show(context, goal: period.goal, provider: widget.provider, historical: period), - ), - ], - if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], - ), - footer: BottomSheetFormActions( - onCancel: _saving || widget.provider.store.getReadingGoal(goal.id) == null ? null : _delete, - cancelLabel: 'Delete goal', - onSave: _saving ? null : () => Navigator.pop(context), - saveLabel: 'Done', + ), + footer: BottomSheetFormActions( + onCancel: _saving || widget.provider.store.getReadingGoal(goal.id) == null ? null : _delete, + cancelLabel: 'Delete goal', + onSave: _saving ? null : () => Navigator.pop(context), + saveLabel: 'Done', + ), ), ); }, diff --git a/app/lib/widgets/goals/goal_timezone_picker.dart b/app/lib/widgets/goals/goal_timezone_picker.dart new file mode 100644 index 00000000..58fcb4e6 --- /dev/null +++ b/app/lib/widgets/goals/goal_timezone_picker.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:timezone/timezone.dart' as tz; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/goals/goal_controls.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; + +String timezoneCity(String name) => name.split('/').last.replaceAll('_', ' '); + +class GoalTimezonePicker extends StatefulWidget { + const GoalTimezonePicker({super.key, required this.selected}); + final String selected; + static Future show(BuildContext context, {required String selected}) => showModalBottomSheet( + context: context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + sheetAnimationStyle: AppMotion.animationStyle(context), + constraints: BoxConstraints(maxWidth: 640, maxHeight: MediaQuery.sizeOf(context).height * .85), + builder: (_) => GoalTimezonePicker(selected: selected), + ); + @override + State createState() => _GoalTimezonePickerState(); +} + +class _GoalTimezonePickerState extends State { + final _search = TextEditingController(); + late final List _zones; + @override + void initState() { + super.initState(); + GoalCalendar.initialize(); + _zones = {'UTC', ...tz.timeZoneDatabase.locations.keys}.toList() + ..sort((a, b) { + if (a == b) return 0; + if (a == widget.selected) return -1; + if (b == widget.selected) return 1; + if (a == 'UTC') return -1; + if (b == 'UTC') return 1; + return a.compareTo(b); + }); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final query = _search.text.trim().toLowerCase(); + final zones = _zones.where((name) => name.toLowerCase().replaceAll('_', ' ').contains(query)).toList(); + return GoalControls( + child: AppBottomSheet( + title: 'Choose timezone', + onClose: () => Navigator.pop(context), + scrollable: false, + expandBody: true, + expandOnScroll: false, + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _search, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration(labelText: 'Search city or timezone', prefixIcon: Icon(Icons.search)), + ), + const SizedBox(height: Spacing.md), + if (zones.isEmpty) + const Expanded(child: Center(child: Text('No matching timezones.'))) + else + Expanded( + child: ListView.builder( + itemCount: zones.length, + itemBuilder: (context, index) { + final name = zones[index]; + return ListTile( + title: Text(timezoneCity(name)), + subtitle: Text(name), + trailing: name == widget.selected ? const Icon(Icons.check) : null, + onTap: () => Navigator.pop(context, name), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/widgets/goals/log_reading_sheet.dart b/app/lib/widgets/goals/log_reading_sheet.dart index d3844980..fe6d8f9d 100644 --- a/app/lib/widgets/goals/log_reading_sheet.dart +++ b/app/lib/widgets/goals/log_reading_sheet.dart @@ -6,6 +6,7 @@ import 'package:papyrus/models/reading_activity.dart'; import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/goals/goal_controls.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; @@ -75,127 +76,147 @@ class _LogReadingSheetState extends State { @override Widget build(BuildContext context) { final books = widget.provider.store.books; - return AppBottomSheet( - title: widget.correcting == null ? 'Log reading' : 'Correct reading entry', - canClose: !_saving, - onClose: () => Navigator.pop(context), - body: Form( - key: _form, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DropdownButtonFormField( - initialValue: books.any((book) => book.id == _bookId) ? _bookId : null, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Book'), - items: [ - for (final book in books) - DropdownMenuItem( - value: book.id, - child: Text(book.title, overflow: TextOverflow.ellipsis), - ), - ], - validator: (value) => value == null ? 'Choose a book.' : null, - onChanged: _saving || widget.correcting != null ? null : (value) => setState(() => _bookId = value), - ), - if (books.isEmpty) - const Padding( - padding: EdgeInsets.only(top: Spacing.sm), - child: Text('Add a digital or physical book to your library before logging reading.'), + return GoalControls( + child: AppBottomSheet( + title: widget.correcting == null ? 'Log reading' : 'Correct reading entry', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Form( + key: _form, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: books.any((book) => book.id == _bookId) ? _bookId : null, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Book'), + items: [ + for (final book in books) + DropdownMenuItem( + value: book.id, + child: Text(book.title, overflow: TextOverflow.ellipsis), + ), + ], + validator: (value) => value == null ? 'Choose a book.' : null, + onChanged: _saving || widget.correcting != null ? null : (value) => setState(() => _bookId = value), ), - const SizedBox(height: Spacing.lg), - Wrap( - spacing: Spacing.md, - runSpacing: Spacing.sm, - children: [ - OutlinedButton.icon( - onPressed: _saving - ? null - : () async { - final date = await showAppDatePicker( - context: context, - initialDate: _end, - firstDate: DateTime(1900), - lastDate: DateTime.now(), - ); - if (date != null && mounted) { - setState(() => _end = DateTime(date.year, date.month, date.day, _end.hour, _end.minute)); - } - }, - icon: const Icon(Icons.event_outlined), - label: Text(DateFormat.yMMMd().format(_end)), - ), - OutlinedButton.icon( - onPressed: _saving - ? null - : () async { - final time = await showDialog( - context: context, - animationStyle: AppMotion.animationStyle(context), - builder: (_) => TimePickerDialog(initialTime: TimeOfDay.fromDateTime(_end)), - ); - if (time != null && mounted) { - setState(() => _end = DateTime(_end.year, _end.month, _end.day, time.hour, time.minute)); - } - }, - icon: const Icon(Icons.schedule), - label: Text(DateFormat.Hm().format(_end)), + if (books.isEmpty) + const Padding( + padding: EdgeInsets.only(top: Spacing.sm), + child: Text('Add a digital or physical book to your library before logging reading.'), ), - ], - ), - const SizedBox(height: Spacing.lg), - TextFormField( - controller: _minutes, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Minutes read', - helperText: 'The session ends at the time above.', + const SizedBox(height: Spacing.lg), + Text('Session ends at', style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final date = await showAppDatePicker( + context: context, + initialDate: _end, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (date != null && mounted) { + setState(() => _end = DateTime(date.year, date.month, date.day, _end.hour, _end.minute)); + } + }, + icon: const Icon(Icons.event_outlined), + label: Text(DateFormat.yMMMd().format(_end)), + ), + OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final time = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (_) => TimePickerDialog(initialTime: TimeOfDay.fromDateTime(_end)), + ); + if (time != null && mounted) { + setState(() => _end = DateTime(_end.year, _end.month, _end.day, time.hour, time.minute)); + } + }, + icon: const Icon(Icons.schedule), + label: Text(DateFormat.Hm().format(_end)), + ), + ], ), - validator: _nonNegative, - ), - const SizedBox(height: Spacing.md), - TextFormField( - controller: _pages, - keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: 'Pages read'), - validator: _nonNegative, - ), - const SizedBox(height: Spacing.md), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - title: const Text('I finished this book'), - value: _finished, - onChanged: _saving ? null : (value) => setState(() => _finished = value!), - ), - const SizedBox(height: Spacing.md), - TextFormField( - controller: _note, - maxLength: 10000, - maxLines: 3, - decoration: const InputDecoration(labelText: 'Note (optional)'), - ), - const SizedBox(height: Spacing.md), - const Text( - 'Manual entries count toward matching goals. Reading before a goal was created stays in your history but does not count toward that goal.', - ), - if (widget.correcting != null) - const Padding( - padding: EdgeInsets.only(top: Spacing.md), - child: Text('The original entry is retained as corrected in your activity history.'), + const SizedBox(height: Spacing.lg), + LayoutBuilder( + builder: (context, constraints) { + final enlarged = MediaQuery.textScalerOf(context).scale(16) > 24; + final width = enlarged + ? constraints.maxWidth + : ((constraints.maxWidth - Spacing.md) / 2).clamp(0, 220).toDouble(); + return Wrap( + spacing: Spacing.md, + runSpacing: Spacing.md, + children: [ + SizedBox( + width: width, + child: TextFormField( + controller: _minutes, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Minutes read'), + validator: _nonNegative, + ), + ), + SizedBox( + width: width, + child: TextFormField( + controller: _pages, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Pages read'), + validator: _nonNegative, + ), + ), + ], + ); + }, ), - if (_error != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + const SizedBox(height: Spacing.md), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: const Text('I finished this book'), + value: _finished, + onChanged: _saving ? null : (value) => setState(() => _finished = value!), ), - ], + const SizedBox(height: Spacing.md), + TextFormField( + controller: _note, + maxLength: 10000, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Note (optional)'), + ), + const SizedBox(height: Spacing.md), + const Text( + 'Manual entries count toward matching goals. Reading before a goal was created stays in your history but does not count toward that goal.', + ), + if (widget.correcting != null) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('The original entry is retained as corrected in your activity history.'), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ), + ), + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context), + onSave: _saving || books.isEmpty ? null : _save, + saveLabel: _saving ? 'Saving…' : 'Save reading', ), - ), - footer: BottomSheetFormActions( - onCancel: _saving ? null : () => Navigator.pop(context), - onSave: _saving || books.isEmpty ? null : _save, - saveLabel: _saving ? 'Saving…' : 'Save reading', ), ); } @@ -234,11 +255,77 @@ class _LogReadingSheetState extends State { } } +/// Book titles and dates belong to groups; individual entries retain their audit actions. +class ReadingActivityList extends StatelessWidget { + const ReadingActivityList({ + super.key, + required this.activities, + required this.provider, + this.correctedIds = const {}, + }); + final List activities; + final GoalsProvider provider; + final Set correctedIds; + @override + Widget build(BuildContext context) { + final entries = [...activities]..sort((a, b) => b.endTime.compareTo(a.endTime)); + final groups = >{}; + for (final entry in entries) { + final day = DateFormat('yyyy-MM-dd').format(entry.endTime.toLocal()); + groups.putIfAbsent('${entry.bookId}:$day', () => []).add(entry); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final group in groups.values) ...[ + Padding( + padding: const EdgeInsets.only(top: Spacing.md, bottom: Spacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Tooltip( + message: group.first.bookTitle, + child: Text( + group.first.bookTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Text( + DateFormat.yMMMd().format(group.first.endTime.toLocal()), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ), + for (final activity in group) + ReadingActivityTile( + activity: activity, + provider: provider, + corrected: correctedIds.contains(activity.id), + showBookTitle: false, + ), + ], + ], + ); + } +} + class ReadingActivityTile extends StatelessWidget { - const ReadingActivityTile({super.key, required this.activity, required this.provider, this.corrected = false}); + const ReadingActivityTile({ + super.key, + required this.activity, + required this.provider, + this.corrected = false, + this.showBookTitle = true, + }); final ReadingActivity activity; final GoalsProvider provider; final bool corrected; + final bool showBookTitle; @override Widget build(BuildContext context) { final label = activity.kind == 'completion' @@ -260,11 +347,23 @@ class ReadingActivityTile extends StatelessWidget { ? Icons.auto_stories_outlined : Icons.edit_outlined, ), - title: Text(activity.bookTitle), - subtitle: Text( - '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}\n${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', + dense: !showBookTitle, + title: Text( + showBookTitle + ? activity.bookTitle + : '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}', + maxLines: showBookTitle ? 2 : null, + overflow: showBookTitle ? TextOverflow.ellipsis : null, ), - isThreeLine: true, + subtitle: showBookTitle + ? Text( + '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}\n${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', + ) + : null, + trailing: showBookTitle + ? null + : Text(DateFormat.Hm().format(activity.startTime.toLocal()), style: Theme.of(context).textTheme.bodySmall), + isThreeLine: showBookTitle, onTap: () => _details(context), ); } @@ -297,76 +396,78 @@ class _ActivityDetailsState extends State<_ActivityDetails> { Widget build(BuildContext context) { final activity = widget.activity; final book = widget.provider.store.getBook(activity.bookId); - return AppBottomSheet( - title: 'Reading activity', - canClose: !_saving, - onClose: () => Navigator.pop(context), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(activity.bookTitle, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: Spacing.md), - Text( - '${activity.source == 'reader' ? 'Recorded by the reader' : 'Logged manually'} · ${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', - ), - const SizedBox(height: Spacing.md), - if (activity.kind == 'reading') + return GoalControls( + child: AppBottomSheet( + title: 'Reading activity', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(activity.bookTitle, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: Spacing.md), Text( - '${activity.seconds ~/ 60} minutes · ${activity.pages > 0 - ? '${activity.pages} manual pages' - : activity.isEstimated - ? 'Estimated EPUB coverage' - : activity.coverage.isEmpty - ? 'No pages logged' - : 'PDF coverage'}', - ), - if (activity.kind == 'completion') const Text('Explicitly marked as finished.'), - if (activity.note != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: Text(activity.note!), - ), - if (widget.corrected) - const Padding( - padding: EdgeInsets.only(top: Spacing.md), - child: Text('This entry was corrected and no longer contributes.'), - ), - if (book == null) - const Padding( - padding: EdgeInsets.only(top: Spacing.md), - child: Text('The book was removed. Its activity history is retained.'), + '${activity.source == 'reader' ? 'Recorded by the reader' : 'Logged manually'} · ${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', ), - if (!widget.corrected && activity.source == 'manual' && book != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: OutlinedButton.icon( - onPressed: _saving - ? null - : () { - final parent = Navigator.of(context, rootNavigator: true).context; - Navigator.pop(context); - LogReadingSheet.show(parent, provider: widget.provider, book: book, correcting: activity); - }, - icon: const Icon(Icons.edit_outlined), - label: const Text('Correct entry'), + const SizedBox(height: Spacing.md), + if (activity.kind == 'reading') + Text( + '${activity.seconds ~/ 60} minutes · ${activity.pages > 0 + ? '${activity.pages} manual pages' + : activity.isEstimated + ? 'Estimated EPUB coverage' + : activity.coverage.isEmpty + ? 'No pages logged' + : 'PDF coverage'}', ), - ), - if (!widget.corrected) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: OutlinedButton.icon( - onPressed: _saving ? null : _undo, - icon: const Icon(Icons.undo), - label: const Text('Undo this entry'), + if (activity.kind == 'completion') const Text('Explicitly marked as finished.'), + if (activity.note != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(activity.note!), ), - ), - if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], - ), - footer: BottomSheetFormActions( - onCancel: null, - onSave: _saving ? null : () => Navigator.pop(context), - saveLabel: 'Done', + if (widget.corrected) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('This entry was corrected and no longer contributes.'), + ), + if (book == null) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('The book was removed. Its activity history is retained.'), + ), + if (!widget.corrected && activity.source == 'manual' && book != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: OutlinedButton.icon( + onPressed: _saving + ? null + : () { + final parent = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + LogReadingSheet.show(parent, provider: widget.provider, book: book, correcting: activity); + }, + icon: const Icon(Icons.edit_outlined), + label: const Text('Correct entry'), + ), + ), + if (!widget.corrected) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: OutlinedButton.icon( + onPressed: _saving ? null : _undo, + icon: const Icon(Icons.undo), + label: const Text('Undo this entry'), + ), + ), + if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ), + footer: BottomSheetFormActions( + onCancel: null, + onSave: _saving ? null : () => Navigator.pop(context), + saveLabel: 'Done', + ), ), ); } diff --git a/app/lib/widgets/goals/reading_activity_heatmap.dart b/app/lib/widgets/goals/reading_activity_heatmap.dart new file mode 100644 index 00000000..99545ff4 --- /dev/null +++ b/app/lib/widgets/goals/reading_activity_heatmap.dart @@ -0,0 +1,338 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/reading_activity_calendar.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/themes/design_tokens.dart'; + +class ReadingActivityHeatmap extends StatefulWidget { + const ReadingActivityHeatmap({ + super.key, + required this.activities, + required this.year, + required this.timezone, + required this.now, + required this.onYearChanged, + required this.onDaySelected, + this.selectedDay, + }); + final List activities; + final int year; + final String timezone; + final DateTime now; + final ValueChanged onYearChanged; + final ValueChanged onDaySelected; + final DateTime? selectedDay; + @override + State createState() => _ReadingActivityHeatmapState(); +} + +class _ReadingActivityHeatmapState extends State { + final _scroll = ScrollController(); + bool _positionPending = true; + double? _lastStride; + @override + void didUpdateWidget(ReadingActivityHeatmap oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.year != widget.year || oldWidget.timezone != widget.timezone) _positionPending = true; + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + Color _color(ColorScheme colors, int level) => level == 0 + ? colors.surfaceContainerHighest + : Color.lerp(colors.surface, colors.primary, [0, .25, .45, .7, 1][level])!; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final texts = Theme.of(context).textTheme; + final range = GoalCalendar.calendarPeriod(GoalPeriod.yearly, DateTime.utc(widget.year, 1, 2), widget.timezone); + final first = range.start; + final activity = readingActivityCalendar( + widget.activities, + range: range, + timezone: widget.timezone, + now: widget.now, + ); + final firstLocal = GoalCalendar.local(first, widget.timezone); + final gridStart = GoalCalendar.dayOffset(first, 1 - firstLocal.weekday, widget.timezone); + final dayCount = DateTime.utc(widget.year + 1).difference(DateTime.utc(widget.year)).inDays; + final columns = ((firstLocal.weekday - 1 + dayCount) / 7).ceil(); + final currentYear = GoalCalendar.local(widget.now, widget.timezone).year; + final years = { + currentYear, + widget.year, + for (final a in widget.activities) GoalCalendar.local(a.startTime, widget.timezone).year, + }.where((year) => year <= currentYear).toList()..sort((a, b) => b.compareTo(a)); + return Container( + key: const Key('reading-activity-heatmap'), + padding: const EdgeInsets.all(Spacing.lg), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: colors.outlineVariant), + ), + child: Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + Semantics(container: true, header: true, child: Text('Reading activity', style: texts.titleLarge)), + SizedBox( + width: MediaQuery.textScalerOf(context).scale(80).clamp(112, 200).toDouble(), + child: Semantics( + container: true, + child: DropdownButtonFormField( + key: ValueKey('activity-year-${widget.year}'), + initialValue: widget.year, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Year', isDense: true), + items: [for (final year in years) DropdownMenuItem(value: year, child: Text('$year'))], + onChanged: (year) { + if (year != null) widget.onYearChanged(year); + }, + ), + ), + ), + ], + ), + const SizedBox(height: Spacing.lg), + LayoutBuilder( + builder: (context, constraints) { + const gap = 3.0; + final labelWidth = MediaQuery.textScalerOf(context).scale(24); + final labelPainter = TextPainter( + text: TextSpan(text: 'M', style: texts.labelSmall), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final minCell = math.max(11.0, labelPainter.height + 2 - gap); + labelPainter.dispose(); + final cell = ((constraints.maxWidth - labelWidth) / columns - gap) + .clamp(minCell, math.max(20.0, minCell)) + .toDouble(); + final stride = cell + gap; + if (_lastStride != stride) { + _lastStride = stride; + _positionPending = true; + } + if (_positionPending) { + _positionPending = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scroll.hasClients) return; + final today = GoalCalendar.local(widget.now, widget.timezone); + final visibleDay = widget.year == today.year + ? DateTime.utc(today.year, today.month, today.day) + : DateTime.utc(widget.year, 12, 31); + final week = + (visibleDay.difference(DateTime.utc(widget.year)).inDays + firstLocal.weekday - 1) ~/ 7; + _scroll.jumpTo( + (labelWidth + (week + 1) * stride - constraints.maxWidth).clamp( + 0, + _scroll.position.maxScrollExtent, + ), + ); + }); + } + final headingHeight = MediaQuery.textScalerOf(context).scale(20); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: labelWidth, + child: Column( + children: [ + SizedBox(height: headingHeight), + for (var day = 0; day < 7; day++) + SizedBox( + height: stride, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: day.isEven + ? Text(['M', '', 'W', '', 'F', '', 'S'][day], style: texts.labelSmall) + : null, + ), + ), + ], + ), + ), + Expanded( + child: Scrollbar( + controller: _scroll, + child: SingleChildScrollView( + controller: _scroll, + scrollDirection: Axis.horizontal, + child: SizedBox( + width: math.max(constraints.maxWidth - labelWidth, columns * stride), + child: Column( + children: [ + SizedBox( + height: headingHeight, + child: Row( + children: [ + for (var week = 0; week < columns; week++) + SizedBox( + width: stride, + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + maxWidth: 64, + child: _monthLabel(week, gridStart, texts), + ), + ), + ], + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var week = 0; week < columns; week++) + Column( + children: [ + for (var weekday = 0; weekday < 7; weekday++) + Padding( + padding: const EdgeInsets.only(right: gap, bottom: gap), + child: _day( + context, + GoalCalendar.dayOffset(gridStart, week * 7 + weekday, widget.timezone), + range, + activity, + cell, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ), + const SizedBox(height: Spacing.md), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: Spacing.sm, + spacing: Spacing.md, + children: [ + Tooltip( + message: 'Dates use ${widget.timezone.replaceAll('_', ' ')}', + excludeFromSemantics: true, + child: Text( + '${activity.length} active ${activity.length == 1 ? 'day' : 'days'}', + style: texts.bodySmall, + ), + ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: Spacing.xs, + children: [ + Text('Less', style: texts.labelSmall), + const SizedBox(width: Spacing.sm), + for (var level = 0; level < 5; level++) + Padding( + padding: const EdgeInsets.only(right: 3), + child: Container( + width: 11, + height: 11, + decoration: BoxDecoration( + color: _color(colors, level), + borderRadius: BorderRadius.circular(2), + border: Border.all(color: colors.outlineVariant), + ), + ), + ), + const SizedBox(width: Spacing.xs), + Text('More', style: texts.labelSmall), + ], + ), + ], + ), + ], + ), + ), + ); + } + + Widget? _monthLabel(int week, DateTime start, TextTheme texts) { + final date = GoalCalendar.local(GoalCalendar.dayOffset(start, week * 7, widget.timezone), widget.timezone); + if (date.year != widget.year || (date.day > 7 && week != 0)) return null; + return Text(DateFormat.MMM().format(date), style: texts.labelSmall); + } + + Widget _day( + BuildContext context, + DateTime day, + GoalRange range, + Map activity, + double size, + ) { + final colors = Theme.of(context).colorScheme; + final inRange = range.contains(day); + final future = day.isAfter(widget.now); + final value = activity[day]; + final seconds = value?.seconds ?? 0; + final duration = seconds == 0 + ? value?.hasActivity == true + ? 'Reading logged' + : 'No reading' + : seconds < 60 + ? '$seconds sec' + : '${seconds ~/ 60} min'; + final label = + '${DateFormat.yMMMd().format(GoalCalendar.local(day, widget.timezone))}: ${future ? 'Future date' : duration}'; + return SizedBox( + width: size, + height: size, + child: inRange + ? Tooltip( + message: label, + excludeFromSemantics: true, + child: Semantics( + label: label, + excludeSemantics: true, + onTap: future ? null : () => widget.onDaySelected(day), + button: !future, + selected: widget.selectedDay == day, + // Paint inside the scrollable, so offscreen cells cannot cover + // the fixed weekday labels. Each cell also owns its focus/ripple. + child: Material( + animationDuration: Duration.zero, + color: future ? colors.surface : _color(colors, value?.level ?? 0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(2), + side: BorderSide( + color: widget.selectedDay == day ? colors.primary : colors.outlineVariant, + width: widget.selectedDay == day ? 2 : .5, + ), + ), + child: InkWell( + onTap: future ? null : () => widget.onDaySelected(day), + borderRadius: BorderRadius.circular(2), + child: const SizedBox.expand(), + ), + ), + ), + ) + : null, + ); + } +} diff --git a/app/lib/widgets/goals/reading_activity_timeline.dart b/app/lib/widgets/goals/reading_activity_timeline.dart new file mode 100644 index 00000000..b0547590 --- /dev/null +++ b/app/lib/widgets/goals/reading_activity_timeline.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/goal_progress.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; + +/// A day/book row summarizes reading; expanding it keeps every audit entry available. +class ReadingActivityTimeline extends StatelessWidget { + const ReadingActivityTimeline({ + super.key, + required this.activities, + required this.provider, + required this.timezone, + this.correctedIds = const {}, + this.range, + }); + final List activities; + final GoalsProvider provider; + final String timezone; + final Set correctedIds; + final GoalRange? range; + + @override + Widget build(BuildContext context) { + final days = >>{}; + final sorted = [...activities]..sort((a, b) => b.endTime.compareTo(a.endTime)); + for (final entry in sorted) { + // A reader interval crossing midnight belongs to each exposed day. Keep + // its original identity so inspecting or correcting it still edits one entry. + final entryDays = {}; + if (entry.kind == 'reading' && entry.endTime.isAfter(entry.startTime)) { + var cursor = entry.startTime; + if (range != null && cursor.isBefore(range!.start)) cursor = range!.start; + final end = range != null && range!.end.isBefore(entry.endTime) ? range!.end : entry.endTime; + while (cursor.isBefore(end)) { + entryDays.add(GoalCalendar.midnight(cursor, timezone)); + cursor = GoalCalendar.nextDay(cursor, timezone); + } + } else if (range == null || range!.contains(entry.endTime)) { + entryDays.add(GoalCalendar.midnight(entry.endTime, timezone)); + } + for (final day in entryDays) { + days.putIfAbsent(day, () => {}).putIfAbsent(entry.bookId, () => []).add(entry); + } + } + final colors = Theme.of(context).colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final day in (days.entries.toList()..sort((a, b) => b.key.compareTo(a.key)))) ...[ + Padding( + padding: const EdgeInsets.only(top: Spacing.lg, bottom: Spacing.sm), + child: Semantics( + container: true, + header: true, + child: Text( + DateFormat.yMMMEd().format(GoalCalendar.local(day.key, timezone)), + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ), + Container( + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: colors.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final group in day.value.values) ...[ + if (group != day.value.values.first) const Divider(height: 1), + _bookRow(context, day.key, group), + ], + ], + ), + ), + ], + ], + ); + } + + Widget _bookRow(BuildContext context, DateTime day, List entries) { + final book = provider.store.getBook(entries.first.bookId); + final colors = Theme.of(context).colorScheme; + final current = entries.where((entry) => !correctedIds.contains(entry.id)).toList(); + final end = GoalCalendar.nextDay(day, timezone); + final totals = projectGoal( + ReadingGoal( + id: 'timeline', + type: GoalType.minutes, + targetValue: 1, + period: GoalPeriod.custom, + createdAt: day, + startDate: day, + endDate: end, + timezone: timezone, + isRecurring: false, + ), + current, + provider.now, + period: GoalRange(day, end), + ); + final sources = current.map((entry) => entry.source == 'reader' ? 'Reader' : 'Manual').toSet(); + final summary = [ + if (totals.finishedBooks > 0) 'Finished', + if (totals.seconds > 0) totals.seconds < 60 ? '${totals.seconds} sec' : formatDuration(totals.seconds ~/ 60), + if (totals.pages > 0) '${totals.pages.floor()} ${totals.estimated ? 'estimated pages' : 'pages'}', + ...sources, + if (entries.any((entry) => correctedIds.contains(entry.id))) 'Corrected entries', + ].join(' · '); + final grouped = [ + ...groupReadingActivities(current), + ...groupReadingActivities(entries.where((entry) => correctedIds.contains(entry.id))), + ]; + return ExpansionTile( + key: ValueKey('activity-book-${entries.first.bookId}-${day.toIso8601String()}'), + tilePadding: const EdgeInsets.all(Spacing.md), + childrenPadding: const EdgeInsets.symmetric(horizontal: Spacing.md), + shape: const Border(), + collapsedShape: const Border(), + leading: SizedBox( + width: 36, + height: 54, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: CoverImage( + bookId: book?.id, + imageUrl: book?.coverUrl, + mediaId: book?.coverMediaId, + placeholder: ColoredBox( + color: colors.primaryContainer, + child: Icon( + totals.finishedBooks > 0 ? Icons.check : Icons.menu_book_outlined, + color: colors.onPrimaryContainer, + size: 20, + ), + ), + ), + ), + ), + title: Tooltip( + message: entries.first.bookTitle, + excludeFromSemantics: true, + child: Text( + entries.first.bookTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: Spacing.xs), + child: Text(summary.isEmpty ? 'Reading activity' : summary), + ), + children: [ + for (final entry in grouped) + ReadingActivityTile( + activity: entry, + provider: provider, + corrected: correctedIds.contains(entry.id), + showBookTitle: false, + ), + ], + ); + } +} diff --git a/app/test/goals/reading_activity_calendar_test.dart b/app/test/goals/reading_activity_calendar_test.dart new file mode 100644 index 00000000..2785deee --- /dev/null +++ b/app/test/goals/reading_activity_calendar_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/goals/goal_calendar.dart'; +import 'package:papyrus/goals/reading_activity_calendar.dart'; +import 'package:papyrus/models/reading_activity.dart'; + +void main() { + final start = DateTime.utc(2026, 10, 7); + ReadingActivity entry(String id, int from, int to, {String kind = 'reading', String? correctionOf, int pages = 0}) => + ReadingActivity( + id: id, + bookId: 'book', + bookTitle: 'Book', + source: 'reader', + startTime: start.add(Duration(minutes: from)), + endTime: start.add(Duration(minutes: to)), + createdAt: start.add(Duration(minutes: to)), + kind: kind, + correctionOf: correctionOf, + pages: pages, + ); + + test('heatmap unions device overlap and retries, excludes corrections and panel gaps', () { + final a = entry('a', 0, 10); + final days = readingActivityCalendar( + [a, a, entry('b', 5, 15), entry('c', 30, 35), entry('undo', 36, 36, kind: 'reversal', correctionOf: 'c')], + range: GoalRange(start, start.add(const Duration(days: 1))), + timezone: 'UTC', + now: start.add(const Duration(hours: 1)), + ); + expect(days[start]!.seconds, 15 * 60); + expect(days[start]!.level, 2); + }); + + test('zero-duration pages and explicit completion count as activity without invented time', () { + final days = readingActivityCalendar( + [entry('pages', 0, 0, pages: 10), entry('finished', 1440, 1440, kind: 'completion')], + range: GoalRange(start, start.add(const Duration(days: 3))), + timezone: 'UTC', + now: start.add(const Duration(days: 2)), + ); + expect(days.length, 2); + expect(days.values.every((day) => day.seconds == 0 && day.level == 1), isTrue); + }); + + test('splits at local midnight across DST and clips future exposure', () { + const timezone = 'Europe/Vilnius'; + final beginning = DateTime.utc(2026, 3, 28, 21, 50); // 23:50 before the spring change + final finish = DateTime.utc(2026, 3, 29, 22); // 01:00 after the change + final days = readingActivityCalendar( + [ + ReadingActivity( + id: 'overnight', + bookId: 'book', + bookTitle: 'Book', + startTime: beginning, + endTime: finish, + createdAt: finish, + ), + ], + range: GoalRange(DateTime.utc(2026, 3, 28), DateTime.utc(2026, 3, 31)), + timezone: timezone, + now: DateTime.utc(2026, 3, 29, 21, 30), + ); + expect(days[DateTime.utc(2026, 3, 28, 22)]!.seconds, 23 * 3600); + expect(days[DateTime.utc(2026, 3, 27, 22)]!.seconds, 10 * 60); + expect(days[DateTime.utc(2026, 3, 29, 21)]!.seconds, 30 * 60); + }); +} diff --git a/app/test/pages/goals_page_test.dart b/app/test/pages/goals_page_test.dart index 11c1e64a..2aabb4a7 100644 --- a/app/test/pages/goals_page_test.dart +++ b/app/test/pages/goals_page_test.dart @@ -32,6 +32,43 @@ void main() { expect(tester.takeException(), isNull); }); } + testWidgets('activity keeps recurring goal periods inspectable on a phone', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(400, 900); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final store = DataStore() + ..loadData( + readingGoals: [ + ReadingGoal( + id: 'recurring', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + startDate: now.subtract(const Duration(days: 3)), + endDate: now.subtract(const Duration(days: 2)), + ), + ], + ); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: AppTheme.dark, home: const GoalsPage()), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Activity')); + await tester.pumpAndSettle(); + final history = find.byKey(const Key('goal-period-history')); + await tester.scrollUntilVisible(history, 250, scrollable: find.byType(Scrollable).first); + await tester.ensureVisible(history); + await tester.pumpAndSettle(); + await tester.tap(find.text('Goal history')); + await tester.pumpAndSettle(); + expect(find.text('Missed'), findsWidgets); + expect(tester.takeException(), isNull); + }); for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { for (final size in [const Size(320, 720), const Size(768, 1024), const Size(1440, 1000)]) { testWidgets('active Goals supports large text at $size in ${theme.brightness}', (tester) async { @@ -80,8 +117,13 @@ void main() { expect(find.text('New goal').hitTestable(), findsOneWidget); expect(find.text('Log reading').hitTestable(), findsOneWidget); if (size.width > 1000) expect(tester.getSize(find.text('Goals')).width, greaterThan(100)); - await tester.tap(find.text('History')); + expect(find.text('Activity').hitTestable(), findsOneWidget); + await tester.tap(find.text('Activity')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Filter dates'), 200, scrollable: find.byType(Scrollable).first); + await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Filter dates')); await tester.pumpAndSettle(); + expect(find.text('Filter dates').hitTestable(), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/app/test/widgets/goals/goals_ui_refinement_test.dart b/app/test/widgets/goals/goals_ui_refinement_test.dart new file mode 100644 index 00000000..83be5f86 --- /dev/null +++ b/app/test/widgets/goals/goals_ui_refinement_test.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/pages/goals_page.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; +import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; +import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; +import 'package:papyrus/widgets/goals/reading_activity_heatmap.dart'; +import 'package:papyrus/widgets/goals/reading_activity_timeline.dart'; + +void main() { + final now = DateTime.now().toUtc(); + final book = Book( + id: 'book', + title: 'A very long book title that would otherwise wrap across several lines in the scope label', + author: '', + addedAt: now, + ); + final short = ReadingGoal( + id: 'short', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + scope: GoalScope.book, + scopeId: book.id, + startDate: now, + endDate: now.add(const Duration(days: 1)), + ); + final days = ReadingGoal( + id: 'days', + type: GoalType.days, + targetValue: 5, + period: GoalPeriod.weekly, + startDate: now, + endDate: now.add(const Duration(days: 7)), + ); + + Future size(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 1100); + addTearDown(tester.view.reset); + } + + Future showSheet(WidgetTester tester, void Function(BuildContext) show) async { + await size(tester); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: Builder( + builder: (context) => TextButton(onPressed: () => show(context), child: const Text('Open')), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + GoalsProvider provider(DataStore store) { + addTearDown(store.dispose); + final value = GoalsProvider(watchClock: false)..attach(store); + addTearDown(value.dispose); + return value; + } + + testWidgets('desktop rows align mixed goal cards and their reading actions', (tester) async { + final semantics = tester.ensureSemantics(); + await size(tester); + final store = DataStore()..loadData(books: [book], readingGoals: [short, days]); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: AppTheme.dark, home: const GoalsPage()), + ), + ); + await tester.pumpAndSettle(); + final left = tester.getRect(find.byKey(const ValueKey('goal-card-short'))); + final right = tester.getRect(find.byKey(const ValueKey('goal-card-days'))); + expect(left.height, right.height); + expect(find.bySemanticsLabel('Continue reading'), findsNWidgets(2)); + semantics.dispose(); + final actions = find.widgetWithText(TextButton, 'Continue reading'); + expect(tester.getRect(actions.at(0)).bottom, tester.getRect(actions.at(1)).bottom); + final log = find.widgetWithText(OutlinedButton, 'Log reading'); + expect(tester.getSize(log).width, lessThan(300)); + final theme = Theme.of(tester.element(log)); + expect(theme.outlinedButtonTheme.style!.shape!.resolve({}), isA()); + expect(theme.filledButtonTheme.style!.shape!.resolve({}), isA()); + await tester.tap(find.text('Activity')); + await tester.pumpAndSettle(); + expect(tester.getSize(find.widgetWithText(OutlinedButton, 'Filter dates')).width, lessThan(300)); + expect(tester.takeException(), isNull); + }); + + testWidgets('goal sheet actions stay content sized on a wide screen', (tester) async { + final store = DataStore()..loadData(readingGoals: [short]); + final goals = provider(store); + await showSheet(tester, (context) => GoalDetailsSheet.show(context, goal: short, provider: goals)); + for (final label in ['Edit goal', 'Pause goal', 'Archive goal']) { + expect(tester.getSize(find.widgetWithText(OutlinedButton, label)).width, lessThan(300)); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('manual date time and numeric controls have compact widths', (tester) async { + final store = DataStore()..loadData(books: [book]); + final goals = provider(store); + await showSheet(tester, (context) => LogReadingSheet.show(context, provider: goals, book: book)); + for (final icon in [Icons.event_outlined, Icons.schedule]) { + expect(tester.getSize(find.widgetWithIcon(OutlinedButton, icon)).width, lessThan(300)); + } + for (final label in ['Minutes read', 'Pages read']) { + expect( + tester.getSize(find.byWidgetPredicate((w) => w is TextField && w.decoration?.labelText == label)).width, + lessThanOrEqualTo(220), + ); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('timezone is advanced, searchable, selectable and saved as an IANA identifier', (tester) async { + final store = DataStore()..loadData(); + final goals = provider(store); + await showSheet( + tester, + (context) => AddGoalSheet.show(context, provider: goals, preset: 0, initialTimezone: 'UTC'), + ); + expect(find.byKey(const Key('goal-timezone-button')).hitTestable(), findsNothing); + expect(find.byWidgetPredicate((w) => w is TextField && w.decoration?.labelText == 'Timezone'), findsNothing); + await tester.tap(find.text('Advanced settings')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('goal-timezone-button'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byWidgetPredicate((w) => w is TextField && w.decoration?.labelText == 'Search city or timezone'), + 'Vilnius', + ); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(ListTile, 'Vilnius')); + await tester.pumpAndSettle(); + expect(find.text('Timezone: Vilnius'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.single.timezone, 'Europe/Vilnius'); + expect(tester.takeException(), isNull); + }); + + for (final scale in [1.0, 2.0]) { + testWidgets('metric chooser and simple form remain usable on a small phone at ${scale}x', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 720); + tester.view.padding = const FakeViewPadding(bottom: 40); + tester.view.viewPadding = const FakeViewPadding(bottom: 40); + addTearDown(tester.view.reset); + final store = DataStore()..loadData(); + final goals = provider(store); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC'), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + final cancel = find.widgetWithText(OutlinedButton, 'Cancel'); + expect(cancel.hitTestable(), findsOneWidget); + expect(tester.getRect(cancel).bottom, lessThanOrEqualTo(680)); + final metric = find.widgetWithText(ListTile, 'Reading days'); + await tester.ensureVisible(metric); + await tester.pumpAndSettle(); + await tester.tap(metric); + await tester.pumpAndSettle(); + final save = find.widgetWithText(FilledButton, 'Create goal'); + expect(save.hitTestable(), findsOneWidget); + expect(tester.getRect(save).bottom, lessThanOrEqualTo(680)); + final target = find.byKey(const Key('goal-target-input')); + await tester.ensureVisible(target); + await tester.pumpAndSettle(); + await tester.enterText(target, '6'); + await tester.tap(save); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.single.targetValue, 6); + expect(store.goalDefinitions.single.type, GoalType.days); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('goal targets and deadline controls stay bounded', (tester) async { + final store = DataStore()..loadData(books: [book]); + final goals = provider(store); + await showSheet( + tester, + (context) => AddGoalSheet.show(context, provider: goals, preset: 4, initialTimezone: 'UTC'), + ); + final target = find.byWidgetPredicate((w) => w is TextFormField && w.key == const Key('goal-target-input')); + expect(tester.getSize(target).width, lessThanOrEqualTo(220)); + expect(tester.getSize(find.widgetWithIcon(OutlinedButton, Icons.event_outlined)).width, lessThan(500)); + expect(tester.takeException(), isNull); + }); + + testWidgets('metric first creation has an editable target and saves the chosen schedule', (tester) async { + final store = DataStore()..loadData(); + final goals = provider(store); + await showSheet(tester, (context) => AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC')); + expect(find.text('What would you like to track?'), findsOneWidget); + await tester.tap(find.widgetWithText(ListTile, 'Pages read')); + await tester.pumpAndSettle(); + final target = find.byKey(const Key('goal-target-input')); + await tester.enterText(target, '73'); + await tester.tap(find.byTooltip('Increase target')); + await tester.pump(); + expect(find.text('74'), findsOneWidget); + await tester.tap(find.byTooltip('Decrease target')); + await tester.tap(find.widgetWithText(ChoiceChip, 'Monthly')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.single.targetValue, 73); + expect(store.goalDefinitions.single.period, GoalPeriod.monthly); + expect(store.goalDefinitions.single.type, GoalType.pages); + expect(tester.takeException(), isNull); + }); + + for (final width in [320.0, 768.0, 1200.0]) { + testWidgets('year heatmap uses the whole available width and dates are selectable at $width', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 800); + addTearDown(tester.view.reset); + final date = DateTime.utc(2026, 10, 7); + DateTime? selected; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: Scaffold( + body: ReadingActivityHeatmap( + activities: [ + ReadingActivity( + id: 'pages', + bookId: 'book', + bookTitle: 'Book', + startTime: date, + endTime: date, + createdAt: date, + pages: 10, + ), + ], + year: 2026, + timezone: 'UTC', + now: date, + onYearChanged: (_) {}, + onDaySelected: (day) => selected = day, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.getSize(find.byKey(const Key('reading-activity-heatmap'))).width, width); + expect(find.text('1 active day'), findsOneWidget); + final today = find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Oct 7, 2026: Reading logged'); + await tester.ensureVisible(today); + await tester.tap(today); + await tester.pump(); + expect(selected, date); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('heatmap weekday labels fit their rows with large e-ink text', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 1000); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(2)), + child: child!, + ), + home: Scaffold( + body: ReadingActivityHeatmap( + activities: const [], + year: 2026, + timezone: 'UTC', + now: DateTime.utc(2026, 10, 7), + onYearChanged: (_) {}, + onDaySelected: (_) {}, + ), + ), + ), + ); + await tester.pumpAndSettle(); + final cell = find.byWidgetPredicate((w) => w is Tooltip && w.message == 'Oct 7, 2026: No reading'); + expect(tester.getSize(find.text('M')).height, lessThanOrEqualTo(tester.getSize(cell).height + 3)); + expect(tester.takeException(), isNull); + }); + + testWidgets('timeline summarizes raw intervals without gaps and keeps entry inspection', (tester) async { + await size(tester); + final store = DataStore()..loadData(books: [book]); + final goals = provider(store); + final date = now.subtract(const Duration(minutes: 10)); + final entries = [ + for (var i = 0; i < 2; i++) + ReadingActivity( + id: 'checkpoint-$i', + sessionId: 'same-opening', + bookId: book.id, + bookTitle: book.title, + source: 'reader', + startTime: date.add(Duration(minutes: i * 3)), + endTime: date.add(Duration(minutes: i * 3 + 1)), + createdAt: now, + ), + ]; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: ReadingActivityTimeline(activities: entries, provider: goals, timezone: 'UTC'), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text(book.title), findsOneWidget); + expect(find.text('2m · Reader'), findsOneWidget); + await tester.tap(find.text(book.title)); + await tester.pumpAndSettle(); + expect(find.text(book.title), findsOneWidget); + await tester.tap(find.widgetWithText(ListTile, '2 min · Reader').last); + await tester.pumpAndSettle(); + expect(find.text('Undo this entry'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('activity groups repeated titles while preserving individual entries and audit actions', (tester) async { + await size(tester); + final store = DataStore()..loadData(books: [book]); + final goals = provider(store); + final entries = [ + for (var i = 0; i < 3; i++) + ReadingActivity( + id: '$i', + bookId: book.id, + bookTitle: book.title, + startTime: now.subtract(Duration(minutes: i + 1)), + endTime: now, + createdAt: now, + source: i == 2 ? 'manual' : 'reader', + pages: i == 2 ? 10 : 0, + ), + ]; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: ReadingActivityList(activities: entries, provider: goals, correctedIds: const {'1'}), + ), + ), + ); + expect(find.text(book.title), findsOneWidget); + expect(find.text('1 min · Reader'), findsOneWidget); + expect(find.text('2 min · Reader · Corrected'), findsOneWidget); + await tester.tap(find.text('1 min · Reader')); + await tester.pumpAndSettle(); + expect(find.text('Undo this entry'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/widgets/shared/bottom_sheet_expansion_test.dart b/app/test/widgets/shared/bottom_sheet_expansion_test.dart index 09d04cca..905492ea 100644 --- a/app/test/widgets/shared/bottom_sheet_expansion_test.dart +++ b/app/test/widgets/shared/bottom_sheet_expansion_test.dart @@ -90,7 +90,7 @@ void main() { } final longSheets = { - 'goal': (context) => AddGoalSheet.show(context, provider: goalProvider(context), initialTimezone: 'UTC'), + 'goal': (context) => AddGoalSheet.show(context, provider: goalProvider(context), preset: 0, initialTimezone: 'UTC'), 'physical book': (context) => AddPhysicalBookSheet.show(context), 'shelf editor': (context) => AddShelfSheet.show(context), 'shelf selection': (context) => MoveToShelfSheet.show(context, book: buildTestBook()), @@ -191,7 +191,7 @@ void main() { testWidgets('desktop sheets keep content sizing and ordinary scrolling', (tester) async { await openSheet( tester, - (context) => AddGoalSheet.show(context, provider: goalProvider(context), initialTimezone: 'UTC'), + (context) => AddGoalSheet.show(context, provider: goalProvider(context), preset: 0, initialTimezone: 'UTC'), size: const Size(1280, 800), ); expect(find.byType(ExpandableBottomSheet), findsNothing); diff --git a/app/test/widgets/shared/bottom_sheet_layouts_test.dart b/app/test/widgets/shared/bottom_sheet_layouts_test.dart index 6c2b800c..2e0a72e1 100644 --- a/app/test/widgets/shared/bottom_sheet_layouts_test.dart +++ b/app/test/widgets/shared/bottom_sheet_layouts_test.dart @@ -30,7 +30,8 @@ void main() { 'topic assignment': (context) => ManageTopicsSheet.show(context, book: book), 'shelf form': (context) => AddShelfSheet.show(context), 'topic form': (context) => AddTopicSheet.show(context), - 'goal form': (context) => AddGoalSheet.show(context, provider: goalProvider(context), initialTimezone: 'UTC'), + 'goal form': (context) => + AddGoalSheet.show(context, provider: goalProvider(context), preset: 0, initialTimezone: 'UTC'), 'bookmark form': (context) => BookmarkDialog.show(context, bookId: book.id), 'annotation form': (context) => AnnotationDialog.show(context, bookId: book.id), 'note form': (context) => NoteDialog.show(context, bookId: book.id), From 5e67e5969a956f4f42c5c330cbc38350f5ac8fd8 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 8 Oct 2026 02:18:39 +0300 Subject: [PATCH 4/4] fix: refine goals UI, scoped targets and activity history --- app/lib/goals/goal_progress.dart | 29 +- app/lib/main.dart | 1 + app/lib/models/reading_goal.dart | 28 +- app/lib/pages/goals_page.dart | 471 +++++++++--------- app/lib/powersync/library_database.dart | 27 +- .../papyrus_powersync_connector.dart | 13 +- app/lib/powersync/powersync_service.dart | 9 +- .../powersync/tracking_schema_version.dart | 6 + app/lib/providers/goals_provider.dart | 22 +- app/lib/widgets/goals/add_goal_sheet.dart | 286 +++++------ app/lib/widgets/goals/goal_card.dart | 48 +- app/lib/widgets/goals/goal_details_sheet.dart | 180 ++++++- app/lib/widgets/goals/log_reading_sheet.dart | 322 +++++++----- .../goals/reading_activity_timeline.dart | 3 +- app/lib/widgets/shared/app_bottom_sheet.dart | 3 + .../widgets/shared/bottom_sheet_actions.dart | 10 +- .../widgets/shared/searchable_book_field.dart | 108 ++++ .../shared/searchable_books_field.dart | 154 ++++++ app/test/goals/fixtures/projections.json | 404 +++++++++++++++ app/test/goals/goal_progress_test.dart | 79 +++ app/test/goals/goals_provider_test.dart | 67 +++ app/test/pages/goals_page_test.dart | 457 ++++++++++++++++- .../powersync/tracking_persistence_test.dart | 54 ++ .../goals/goal_details_sheets_test.dart | 155 +++++- .../goals/goals_ui_refinement_test.dart | 213 +++++++- .../goals/reading_activity_details_test.dart | 154 ++++++ docs/goals-tracking.md | 30 +- docs/images/goals-review/activity-desktop.png | Bin 0 -> 63957 bytes docs/images/goals-review/new-goal.png | Bin 0 -> 47020 bytes docs/images/goals-review/overview-desktop.png | Bin 0 -> 55627 bytes docs/images/goals-review/overview-mobile.png | Bin 0 -> 23925 bytes 31 files changed, 2684 insertions(+), 649 deletions(-) create mode 100644 app/lib/powersync/tracking_schema_version.dart create mode 100644 app/lib/widgets/shared/searchable_book_field.dart create mode 100644 app/lib/widgets/shared/searchable_books_field.dart create mode 100644 app/test/widgets/goals/reading_activity_details_test.dart create mode 100644 docs/images/goals-review/activity-desktop.png create mode 100644 docs/images/goals-review/new-goal.png create mode 100644 docs/images/goals-review/overview-desktop.png create mode 100644 docs/images/goals-review/overview-mobile.png diff --git a/app/lib/goals/goal_progress.dart b/app/lib/goals/goal_progress.dart index 81fecd4a..f8dc6e88 100644 --- a/app/lib/goals/goal_progress.dart +++ b/app/lib/goals/goal_progress.dart @@ -14,6 +14,9 @@ class GoalProgress { required this.activities, required this.estimated, required this.qualifiedDays, + this.activityIntervals = const {}, + this.hasOverlappingTime = false, + this.hasCreationCutoff = false, }); final ReadingGoal goal; final GoalRange range; @@ -24,6 +27,15 @@ class GoalProgress { final List activities; final bool estimated; final Set qualifiedDays; + + /// Eligible intervals for presentation only; the ledger remains unchanged. + final Map> activityIntervals; + final bool hasOverlappingTime; + final bool hasCreationCutoff; + int eligibleSecondsFor(ReadingActivity activity) => unionLength([ + for (final id in activity.constituentIds.isEmpty ? [activity.id] : activity.constituentIds) + ...?activityIntervals[id], + ]).floor(); int get value => switch (goal.type) { GoalType.books => finishedBooks, GoalType.pages => pages.floor(), @@ -60,7 +72,7 @@ GoalRule ruleAt(ReadingGoal goal, DateTime at) { bool matchesGoal(ReadingGoal goal, ReadingActivity activity) => switch (goal.scope) { GoalScope.library => true, - GoalScope.book => activity.bookId == goal.scopeId, + GoalScope.book => goal.selectedBookIds.contains(activity.bookId), GoalScope.shelf => activity.shelfIds.contains(goal.scopeId), }; @@ -104,6 +116,8 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge final scales = {}; final books = {}; final counted = []; + final activityIntervals = >{}; + var hasCreationCutoff = false; var manualPages = 0; var estimated = false; final boundaries = @@ -153,10 +167,10 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge } final active = ruleAt(definition, cursor); if (active.active && !active.archived) { - times.putIfAbsent(day, () => []).add(( - cursor.microsecondsSinceEpoch / 1000000, - stop.microsecondsSinceEpoch / 1000000, - )); + final interval = (cursor.microsecondsSinceEpoch / 1000000, stop.microsecondsSinceEpoch / 1000000); + times.putIfAbsent(day, () => []).add(interval); + activityIntervals.putIfAbsent(activity.id, () => []).add(interval); + hasCreationCutoff |= activity.startTime.isBefore(definition.createdAt); contributed = true; } cursor = stop; @@ -183,5 +197,10 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge activities: counted, estimated: estimated, qualifiedDays: qualified, + activityIntervals: activityIntervals, + hasCreationCutoff: hasCreationCutoff, + hasOverlappingTime: + activityIntervals.values.fold(0, (total, ranges) => total + unionLength([...ranges])) > + dailySeconds.values.fold(0, (a, b) => a + b) + .000001, ); } diff --git a/app/lib/main.dart b/app/lib/main.dart index c54131fa..e5df180d 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -231,6 +231,7 @@ class _PapyrusState extends State { }, connectorFactory: () => PapyrusPowerSyncConnector( supportsTracking: () => _powerSyncService.supportsTracking, + trackingSchemaVersion: () => _powerSyncService.trackingSchemaVersion, authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig, onUploadComplete: () async { diff --git a/app/lib/models/reading_goal.dart b/app/lib/models/reading_goal.dart index 0dcbe3de..36aa1c3d 100644 --- a/app/lib/models/reading_goal.dart +++ b/app/lib/models/reading_goal.dart @@ -64,6 +64,7 @@ class ReadingGoal { final String timezone; final GoalScope scope; final String? scopeId; + final List bookIds; final int minimumMinutes; final List rules; final bool isActive; @@ -86,6 +87,7 @@ class ReadingGoal { this.timezone = 'UTC', this.scope = GoalScope.library, this.scopeId, + this.bookIds = const [], this.minimumMinutes = 5, this.rules = const [], this.isActive = true, @@ -95,17 +97,23 @@ class ReadingGoal { this.completedAt, this.estimatedPages = false, }) : createdAt = createdAt ?? startDate; + List get selectedBookIds => scope != GoalScope.book + ? const [] + : bookIds.isNotEmpty + ? bookIds + : [?scopeId]; int get target => targetValue; int get current => currentValue; double get progress => targetValue == 0 ? 0 : (currentValue / targetValue).clamp(0.0, 1.0); int get remaining => (targetValue - currentValue).clamp(0, targetValue); bool get isCompleted => currentValue >= targetValue; String get progressLabel => '${(progress * 100).round()}%'; - String get typeLabel => switch (type) { - GoalType.books => 'books', - GoalType.pages => 'pages', - GoalType.minutes => 'minutes', - GoalType.days => 'days', + String get typeLabel => unitLabel(2); + String unitLabel(int quantity) => switch (type) { + GoalType.books => quantity == 1 ? 'book' : 'books', + GoalType.pages => quantity == 1 ? 'page' : 'pages', + GoalType.minutes => quantity == 1 ? 'minute' : 'minutes', + GoalType.days => quantity == 1 ? 'day' : 'days', }; String get periodLabel => switch (period) { GoalPeriod.daily => 'daily', @@ -120,12 +128,12 @@ class ReadingGoal { String get description => goalDescription ?? (type == GoalType.days - ? 'Read on $targetValue days $periodLabel' - : 'Read ${type == GoalType.minutes ? formatDuration(targetValue) : '$targetValue $typeLabel'} $periodLabel'); + ? 'Read on $targetValue ${unitLabel(targetValue)} $periodLabel' + : 'Read ${type == GoalType.minutes ? formatDuration(targetValue) : '$targetValue ${unitLabel(targetValue)}'} $periodLabel'); String get displayTitle => title?.trim().isNotEmpty == true ? title! : description; String get statusText => isCompleted ? 'Target reached' - : '${type == GoalType.minutes ? formatDuration(remaining) : '$remaining $typeLabel'} to go'; + : '${type == GoalType.minutes ? formatDuration(remaining) : '$remaining ${unitLabel(remaining)}'} to go'; String get recurrenceLabel => isRecurring && !isCustomPeriod ? 'Recurring' : 'One-off'; ReadingGoal copyWith({ String? id, @@ -141,6 +149,7 @@ class ReadingGoal { String? timezone, GoalScope? scope, String? scopeId, + List? bookIds, int? minimumMinutes, List? rules, bool? isActive, @@ -163,6 +172,7 @@ class ReadingGoal { timezone: timezone ?? this.timezone, scope: scope ?? this.scope, scopeId: scopeId ?? this.scopeId, + bookIds: bookIds ?? this.bookIds, minimumMinutes: minimumMinutes ?? this.minimumMinutes, rules: rules ?? this.rules, isActive: isActive ?? this.isActive, @@ -185,6 +195,7 @@ class ReadingGoal { 'timezone': timezone, 'scope': scope.name, 'scope_id': scopeId, + if (bookIds.length > 1) 'book_ids': bookIds, 'minimum_minutes': minimumMinutes, 'rules': rules.map((rule) => rule.toJson()).toList(), 'is_active': isActive, @@ -205,6 +216,7 @@ class ReadingGoal { timezone: json['timezone'] as String? ?? 'UTC', scope: GoalScope.values.byName(json['scope'] as String? ?? 'library'), scopeId: json['scope_id'] as String?, + bookIds: (json['book_ids'] as List? ?? []).cast(), minimumMinutes: json['minimum_minutes'] as int? ?? 5, rules: (json['rules'] as List? ?? []) .map((value) => GoalRule.fromJson(Map.from(value as Map))) diff --git a/app/lib/pages/goals_page.dart b/app/lib/pages/goals_page.dart index 9826f5ab..6024fc8f 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -1,29 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/goals/goal_progress.dart'; import 'package:papyrus/goals/goal_calendar.dart'; -import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/reading_activity.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/providers/goals_provider.dart'; -import 'package:papyrus/providers/enums/library_reading_status.dart'; -import 'package:papyrus/reader/reader_book_adapter.dart'; -import 'package:papyrus/services/book_import_service_stub.dart' - if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; -import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; import 'package:papyrus/widgets/goals/goal_card.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:papyrus/widgets/goals/goal_controls.dart'; import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; import 'package:papyrus/widgets/goals/reading_activity_heatmap.dart'; import 'package:papyrus/widgets/goals/reading_activity_timeline.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; -import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; @@ -33,17 +27,20 @@ class GoalsPage extends StatefulWidget { State createState() => _GoalsPageState(); } -class _GoalsPageState extends State with SingleTickerProviderStateMixin { +class _GoalsPageState extends State with TickerProviderStateMixin { final _provider = GoalsProvider(); bool _activity = false; - late final TabController _tabs; + bool _hideCompleted = false; + late TabController _tabs; @override void initState() { super.initState(); - _tabs = TabController(length: 2, vsync: this, animationDuration: Duration.zero); - _tabs.addListener(() { - if (_activity != (_tabs.index == 1)) setState(() => _activity = _tabs.index == 1); - }); + _tabs = TabController(length: 2, vsync: this); + _tabs.addListener(_onTabChanged); + } + + void _onTabChanged() { + if (_activity != (_tabs.index == 1)) setState(() => _activity = _tabs.index == 1); } String? _filterGoal; @@ -53,11 +50,20 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix @override void didChangeDependencies() { super.didChangeDependencies(); + final duration = AppMotion.duration(context, kTabScrollDuration); + if (_tabs.animationDuration != duration) { + final index = _tabs.index; + _tabs.removeListener(_onTabChanged); + _tabs.dispose(); + _tabs = TabController(length: 2, vsync: this, initialIndex: index, animationDuration: duration); + _tabs.addListener(_onTabChanged); + } _provider.attach(context.read()); } @override void dispose() { + _tabs.removeListener(_onTabChanged); _tabs.dispose(); _provider.dispose(); super.dispose(); @@ -68,83 +74,31 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix animation: _provider, builder: (context, _) => GoalControls( child: Scaffold( + floatingActionButton: _usesFab(context) + ? FloatingActionButton( + tooltip: 'New goal', + onPressed: () => AddGoalSheet.show(context, provider: _provider), + child: const Icon(Icons.add), + ) + : null, body: SafeArea( child: LayoutBuilder( builder: (context, constraints) { - final padding = constraints.maxWidth < Breakpoints.tablet ? Spacing.md : Spacing.xl; - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1200), - child: ListView( - padding: EdgeInsets.all(padding), - children: [ - LayoutBuilder( - builder: (context, box) { - final actions = Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), - ), - onPressed: () => LogReadingSheet.show(context, provider: _provider), - icon: const Icon(Icons.edit_outlined), - label: const Text('Log reading'), - ), - FilledButton.icon( - style: FilledButton.styleFrom( - minimumSize: const Size(0, ComponentSizes.buttonHeightMobile), - ), - onPressed: () => AddGoalSheet.show(context, provider: _provider), - icon: const Icon(Icons.add), - label: const Text('New goal'), - ), - ], - ); - if (box.maxWidth < 520 || MediaQuery.textScalerOf(context).scale(16) > 24) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Goals', style: Theme.of(context).textTheme.headlineMedium), - const SizedBox(height: Spacing.md), - actions, - ], - ); - } - return Row( - children: [ - Expanded(child: Text('Goals', style: Theme.of(context).textTheme.headlineMedium)), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - actions.children.first, - const SizedBox(width: Spacing.sm), - actions.children.last, - ], - ), - ], - ); - }, - ), - const SizedBox(height: Spacing.lg), - Align(alignment: AlignmentDirectional.centerStart, child: _viewTabs(context)), - const SizedBox(height: Spacing.xl), - if (_provider.isLoading) - const Center(child: AppCircularProgressIndicator()) - else if (_activity) - ..._activityContent(context) - else - ..._overview(context), - if (_provider.error != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: Text(_provider.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ), - ], + final padding = libraryPageHorizontalPadding(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding(padding: EdgeInsets.fromLTRB(padding, padding, padding, 0), child: _viewTabs(context)), + Expanded( + child: TabBarView( + controller: _tabs, + children: [ + _tabContent(context, padding, activity: false), + _tabContent(context, padding, activity: true), + ], + ), ), - ), + ], ); }, ), @@ -153,15 +107,150 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix ), ); - Widget _viewTabs(BuildContext context) { + Widget _tabContent(BuildContext context, double padding, {required bool activity}) { + if (!activity && !_provider.isLoading && _provider.error == null) { + if (_provider.current.isEmpty) { + return EmptyState( + key: const Key('goals-empty-state'), + icon: Icons.emoji_events_outlined, + title: 'No goals yet', + subtitle: 'Create a goal to track your reading progress.', + action: EmptyStateAction( + label: 'New goal', + icon: Icons.add, + onPressed: () => AddGoalSheet.show(context, provider: _provider), + ), + ); + } + if (_hideCompleted && _provider.current.every((progress) => progress.reached)) { + return const EmptyState.compact(icon: Icons.check_circle_outline, title: 'All goals completed'); + } + } + return ListView( + key: PageStorageKey(activity ? 'goals-activity-scroll' : 'goals-overview-scroll'), + padding: EdgeInsets.fromLTRB( + padding, + Spacing.lg, + padding, + // Let the final row scroll clear of the creation FAB. + padding + (_usesFab(context) ? kToolbarHeight + Spacing.lg : 0), + ), + children: [ + if (_provider.isLoading) + const Center(child: AppCircularProgressIndicator()) + else if (activity) + ..._activityContent(context) + else + ..._overview(context), + if (_provider.error != null) + Padding( + padding: const EdgeInsets.only(top: Spacing.md), + child: Text(_provider.error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ), + ], + ); + } + + bool _usesFab(BuildContext context) => MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + + Widget _pageActions(BuildContext context, {required bool compact}) => Wrap( + spacing: Spacing.sm, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (compact) + IconButton( + tooltip: 'Log reading', + onPressed: () => LogReadingSheet.show(context, provider: _provider), + icon: const Icon(Icons.edit_outlined), + ) + else + OutlinedButton.icon( + onPressed: () => LogReadingSheet.show(context, provider: _provider), + icon: const Icon(Icons.edit_outlined), + label: const Text('Log reading'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, TouchTargets.desktopRecommended), + padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), + visualDensity: VisualDensity.standard, + textStyle: Theme.of(context).textTheme.labelLarge, + shape: const StadiumBorder(), + ), + ), + if (!_usesFab(context)) + if (compact) + IconButton.filled( + tooltip: 'New goal', + onPressed: () => AddGoalSheet.show(context, provider: _provider), + icon: const Icon(Icons.add), + ) + else + LibraryAddButton( + label: 'New goal', + onPressed: () => AddGoalSheet.show(context, provider: _provider), + ), + ], + ); + + Widget _viewTabs(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LayoutBuilder( + builder: (context, constraints) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _tabControl(context)), + if (!_activity && _provider.current.isNotEmpty) + Semantics( + value: _hideCompleted ? 'Completed goals hidden' : 'Showing completed goals', + child: PopupMenuButton( + tooltip: 'Goal filters', + icon: Badge( + isLabelVisible: _hideCompleted, + backgroundColor: Theme.of(context).colorScheme.primary, + child: const Icon(Icons.filter_list), + ), + onSelected: (value) => setState(() => _hideCompleted = value), + itemBuilder: (_) => [ + CheckedPopupMenuItem( + value: !_hideCompleted, + checked: _hideCompleted, + child: const Text('Hide completed'), + ), + ], + ), + ), + const SizedBox(width: Spacing.sm), + // Desktop controls use the same 40px target as collection actions. + // Top-align their painted bounds in the standard 48px tab row. + Theme( + data: Theme.of(context).copyWith( + materialTapTargetSize: _usesFab(context) + ? Theme.of(context).materialTapTargetSize + : MaterialTapTargetSize.shrinkWrap, + ), + child: _pageActions( + context, + compact: + _usesFab(context) || constraints.maxWidth < 640 || MediaQuery.textScalerOf(context).scale(1) > 1.4, + ), + ), + ], + ), + ), + const Divider(height: 1), + ], + ); + + Widget _tabControl(BuildContext context) { if (MediaQuery.textScalerOf(context).scale(16) <= 24) { return TabBar( controller: _tabs, isScrollable: true, tabAlignment: TabAlignment.start, indicatorSize: TabBarIndicatorSize.label, - dividerHeight: 1, - labelPadding: const EdgeInsets.symmetric(horizontal: Spacing.md), + dividerHeight: 0, + dividerColor: Theme.of(context).colorScheme.outlineVariant, + labelPadding: EdgeInsets.symmetric(horizontal: _usesFab(context) ? Spacing.sm : Spacing.md), tabs: const [ Tab(text: 'Overview'), Tab(text: 'Activity'), @@ -195,73 +284,39 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix List _overview(BuildContext context) { final current = _provider.current; - final today = current - .where((value) => value.goal.period == GoalPeriod.daily || value.goal.type == GoalType.days) - .toList(); - final longer = current.where((value) => !today.contains(value)).toList(); + // Recurrence describes a goal's schedule; activity determines its place here. + final groups = >{'In progress': [], 'Not started': [], 'Completed': [], 'Paused': []}; + for (final progress in current) { + if (_hideCompleted && progress.reached) continue; + final status = !progress.projected.isActive + ? 'Paused' + : progress.reached + ? 'Completed' + : progress.fraction > 0 + ? 'In progress' + : 'Not started'; + groups[status]!.add(progress); + } + for (final goals in groups.values) { + goals.sort((a, b) { + final progress = b.fraction.compareTo(a.fraction); + if (progress != 0) return progress; + final deadline = a.range.end.compareTo(b.range.end); + if (deadline != 0) return deadline; + final created = a.goal.createdAt.compareTo(b.goal.createdAt); + return created != 0 ? created : a.goal.id.compareTo(b.goal.id); + }); + } final texts = Theme.of(context).textTheme; return [ - if (current.isEmpty) ...[ - const EmptyState.compact( - icon: Icons.flag_outlined, - title: 'No goals yet', - subtitle: - 'Choose a target that fits your reading. The reader tracks progress, and you can log physical books too.', - alignment: Alignment.topCenter, - ), - Wrap( - spacing: Spacing.sm, - runSpacing: Spacing.sm, - children: [ - for (var i = 0; i < 5; i++) - ActionChip( - label: Text( - [ - '30 minutes daily', - '5 reading days weekly', - '12 books yearly', - '100 pages weekly', - 'Finish a book', - ][i], - ), - onPressed: () => AddGoalSheet.show(context, provider: _provider, preset: i), - ), - ], - ), - ], - if (today.isNotEmpty) ...[ - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: Spacing.md, - children: [ - Text('Today', style: texts.titleLarge), - Text( - DateFormat.MMMMEEEEd().format(DateTime.now()), - style: texts.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), - const SizedBox(height: Spacing.md), - _cards(context, today), - const SizedBox(height: Spacing.xl), - ], - if (longer.isNotEmpty) ...[ - Text('Longer-term goals', style: texts.titleLarge), - const SizedBox(height: Spacing.md), - _cards(context, longer), - const SizedBox(height: Spacing.xl), - ], - if (_provider.store.effectiveReadingActivities.isNotEmpty) - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: () => _tabs.index = 1, - icon: const Icon(Icons.history), - label: const Text('View activity'), - ), - ), + for (final group in groups.entries) + if (group.value.isNotEmpty) ...[ + Text(group.key, style: texts.titleLarge), + const SizedBox(height: Spacing.md), + _cards(context, group.value), + const SizedBox(height: Spacing.xl), + ], ]; } @@ -274,16 +329,12 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix goal: progress.goal, progress: progress, scopeLabel: _scopeLabel(progress.goal), - bookProgressLabel: progress.goal.scope == GoalScope.book && progress.goal.type == GoalType.books && !historical - ? '${((_provider.store.getBook(progress.goal.scopeId!)?.currentPosition ?? 0) * 100).round()}% through the book · Finish confirmation required' - : null, onTap: () => GoalDetailsSheet.show( context, goal: progress.goal, provider: _provider, historical: historical ? progress : null, ), - onContinue: historical ? null : () => _continueReading(progress.goal), onMenu: historical ? null : (action) => _goalAction(progress.goal, action), ); return Column( @@ -311,7 +362,10 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix ); String _scopeLabel(ReadingGoal goal) => switch (goal.scope) { GoalScope.library => 'Whole library', - GoalScope.book => _provider.store.getBook(goal.scopeId!)?.title ?? 'Removed book', + GoalScope.book => + goal.selectedBookIds.length > 1 + ? '${goal.selectedBookIds.length} selected books' + : _provider.store.getBook(goal.scopeId!)?.title ?? 'Removed book', GoalScope.shelf => _provider.store.getShelf(goal.scopeId!)?.name ?? 'Removed shelf', }; List _activityContent(BuildContext context) { @@ -525,20 +579,26 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix const SizedBox(height: Spacing.lg), if (filtered.isNotEmpty || archived.isNotEmpty) ExpansionTile( - key: const Key('goal-period-history'), + key: const PageStorageKey('goal-period-history'), tilePadding: EdgeInsets.zero, - title: const Text('Goal history'), - subtitle: const Text('Past periods and archived goals'), + childrenPadding: const EdgeInsets.only(top: Spacing.xs), + shape: const Border(), + collapsedShape: const Border(), + textColor: Theme.of(context).colorScheme.onSurface, + collapsedTextColor: Theme.of(context).colorScheme.onSurface, + title: Text('Goal history', style: Theme.of(context).textTheme.titleLarge), children: [ - if (filtered.isNotEmpty) ...[ - const SizedBox(height: Spacing.md), - _cards(context, filtered, historical: true), - ], + if (filtered.isNotEmpty) _cards(context, filtered, historical: true), if (archived.isNotEmpty) ...[ - const SizedBox(height: Spacing.lg), + if (filtered.isNotEmpty) const SizedBox(height: Spacing.lg), Align( alignment: AlignmentDirectional.centerStart, - child: Text('Archived goals', style: Theme.of(context).textTheme.titleMedium), + child: Text( + 'Archived goals', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), ), const SizedBox(height: Spacing.md), _cards(context, archived, historical: true), @@ -562,11 +622,14 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix await AddGoalSheet.show(context, provider: _provider, editing: _provider.store.getReadingGoal(goal.id)); return; } - if (action == 'delete') { - await GoalDetailsSheet.show(context, goal: goal, provider: _provider); - return; - } + final repository = _provider.store.trackingRepository; try { + if (action == 'delete') { + if (await confirmGoalDeletion(context) && mounted) { + await _provider.deleteGoal(goal.id, repository: repository); + } + return; + } if (action == 'pause') await _provider.pauseGoal(goal.id, goal.isActive); if (action == 'archive') { await _provider.archiveGoal(goal.id); @@ -585,68 +648,4 @@ class _GoalsPageState extends State with SingleTickerProviderStateMix } Future _restore(ReadingGoal goal) => _provider.restoreGoal(goal.id); - Future _continueReading(ReadingGoal goal) async { - final candidates = - _provider.store.books - .where( - (book) => - goal.scope == GoalScope.library || - goal.scope == GoalScope.book && book.id == goal.scopeId || - goal.scope == GoalScope.shelf && _provider.store.getShelfIdsForBook(book.id).contains(goal.scopeId), - ) - .toList() - ..sort((a, b) => (b.lastReadAt ?? b.addedAt).compareTo(a.lastReadAt ?? a.addedAt)); - if (candidates.isEmpty) { - await LogReadingSheet.show(context, provider: _provider); - return; - } - Book? selected; - final readable = candidates - .where( - (book) => - !book.isPhysical && - ReaderBookAdapter.formatFor(book.fileFormat) != null && - book.readingStatus == LibraryReadingStatus.inProgress, - ) - .toList(); - if (readable.isNotEmpty) { - selected = readable.first; - } else if (goal.scope == GoalScope.book) { - selected = candidates.first; - } else { - selected = await showModalBottomSheet( - context: context, - useRootNavigator: true, - useSafeArea: true, - isScrollControlled: true, - sheetAnimationStyle: AppMotion.animationStyle(context), - builder: (context) => AppBottomSheet( - title: 'Choose a book', - onClose: () => Navigator.pop(context), - body: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final book in candidates) - ListTile( - title: Text(book.title), - subtitle: Text(book.isPhysical ? 'Log physical-book reading' : book.author), - onTap: () => Navigator.pop(context, book), - ), - ], - ), - ), - ); - } - if (selected == null || !mounted) return; - var available = selected.fileMediaId != null; - try { - available |= await context.read().hasBookFile(selected.id); - } catch (_) {} - if (!mounted) return; - if (selected.isPhysical || ReaderBookAdapter.formatFor(selected.fileFormat) == null || !available) { - await LogReadingSheet.show(context, provider: _provider, book: selected); - return; - } - context.goNamed('BOOK_READER', pathParameters: {'bookId': selected.id}); - } } diff --git a/app/lib/powersync/library_database.dart b/app/lib/powersync/library_database.dart index 7cde2cbb..f92b7ecc 100644 --- a/app/lib/powersync/library_database.dart +++ b/app/lib/powersync/library_database.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:papyrus/powersync/tracking_schema_version.dart'; import 'package:papyrus/data/repositories/tracking_repository.dart'; import 'package:papyrus/models/reading_goal.dart'; import 'package:papyrus/models/reading_activity.dart'; @@ -16,7 +17,9 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { final PowerSyncDatabase database; final Future Function() onWrite; bool active = true; - bool trackingSupported = false; + int trackingSchemaVersion = 0; + bool get trackingSupported => trackingSchemaVersion > 0; + set trackingSupported(bool value) => trackingSchemaVersion = value ? 2 : 0; @override bool get isCurrent => active; @@ -194,30 +197,27 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { ); }); - Future enableTracking() async { - final previous = trackingSupported; + Future enableTracking({int schemaVersion = 2}) async { + final previous = trackingSchemaVersion; try { await write((tx) async { - trackingSupported = true; + trackingSchemaVersion = schemaVersion; final staged = await tx.getAll('SELECT * FROM tracking_staging ORDER BY table_name DESC'); for (final table in trackingTableNames) { for (final row in staged.where((row) => row['table_name'] == table)) { + final payload = Map.from(jsonDecode(row['payload'] as String) as Map); + if (requiredTrackingSchemaVersion(payload) > schemaVersion) continue; if (row['deleted'] == 1) { await tx.execute('DELETE FROM $table WHERE id = ?', [row['row_id']]); } else { - await _trackingRow( - tx, - table, - Map.from(jsonDecode(row['payload'] as String) as Map), - force: true, - ); + await _trackingRow(tx, table, payload, force: true); } await tx.execute('DELETE FROM tracking_staging WHERE id = ?', [row['id']]); } } }); } catch (_) { - trackingSupported = previous; + trackingSchemaVersion = previous; rethrow; } } @@ -230,7 +230,10 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { bool force = false, }) async { final id = payload['id'] as String; - if (!trackingSupported) { + final staged = deleted + ? await tx.getOptional('SELECT id FROM tracking_staging WHERE id = ?', ['$table:$id']) + : null; + if (requiredTrackingSchemaVersion(payload) > trackingSchemaVersion || staged != null) { await tx.execute( 'INSERT OR REPLACE INTO tracking_staging (id, table_name, row_id, payload, deleted) VALUES (?, ?, ?, ?, ?)', ['$table:$id', table, id, jsonEncode(payload), deleted ? 1 : 0], diff --git a/app/lib/powersync/papyrus_powersync_connector.dart b/app/lib/powersync/papyrus_powersync_connector.dart index be186e8e..69033cbc 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:papyrus/powersync/tracking_schema_version.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; @@ -11,12 +12,14 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { final PapyrusApiConfig config; final Future Function()? onUploadComplete; final bool Function()? supportsTracking; + final int Function()? trackingSchemaVersion; PapyrusPowerSyncConnector({ required this.authRepository, required this.config, this.onUploadComplete, this.supportsTracking, + this.trackingSchemaVersion, }); @override @@ -47,9 +50,13 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { return; } - final deferred = supportsTracking?.call() == false - ? transaction.crud.where((entry) => trackingTableNames.contains(entry.table)).toList() - : []; + final version = supportsTracking?.call() == false ? 0 : trackingSchemaVersion?.call() ?? 2; + final deferred = []; + for (final entry in transaction.crud.where((entry) => trackingTableNames.contains(entry.table))) { + final raw = entry.opData?['payload']; + final payload = raw is String ? Map.from(jsonDecode(raw) as Map) : {}; + if (requiredTrackingSchemaVersion(payload) > version) deferred.add(entry); + } if (deferred.isNotEmpty) { await database.writeTransaction((tx) async { for (final entry in deferred) { diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index e364c488..a2210f67 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -64,6 +64,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac LibraryDatabase? _library; LibrarySnapshot? _snapshot; bool get supportsTracking => _library?.trackingSupported == true; + int get trackingSchemaVersion => _library?.trackingSchemaVersion ?? 0; final _libraryController = StreamController.broadcast(); LibraryDatabase get _activeLibrary { @@ -293,11 +294,13 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac bool _checkingTracking = false; Future _prepareTracking() async { final library = _activeLibrary; - if (_checkingTracking || library.trackingSupported) return; + if (_checkingTracking || library.trackingSchemaVersion >= 2) return; _checkingTracking = true; try { final version = await trackingCapability?.call() ?? 0; - if (library.active && version >= 1) await library.enableTracking(); + if (library.active && version >= 1 && version != library.trackingSchemaVersion) { + await library.enableTracking(schemaVersion: version); + } } catch (_) { // Discovery failure keeps tracking local and does not prevent library sync. } finally { @@ -333,7 +336,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac } Future _setStatusFromPowerSync(SyncStatus status) async { - if (status.connected && _library?.trackingSupported == false) unawaited(_prepareTracking()); + if (status.connected && trackingSchemaVersion < 2) unawaited(_prepareTracking()); final revision = _syncStateRevisions.beginTransportUpdate(); final pending = await _readPendingWrites(); if (!_syncStateRevisions.isCurrent(revision)) return; diff --git a/app/lib/powersync/tracking_schema_version.dart b/app/lib/powersync/tracking_schema_version.dart new file mode 100644 index 00000000..a9da307f --- /dev/null +++ b/app/lib/powersync/tracking_schema_version.dart @@ -0,0 +1,6 @@ +/// Multiple-book scopes require tracking schema v2; ordinary tracking stays v1. +int requiredTrackingSchemaVersion(Map payload) { + final definition = payload['definition']; + final goal = definition is Map ? definition : payload; + return (goal['book_ids'] as List? ?? const []).length > 1 ? 2 : 1; +} diff --git a/app/lib/providers/goals_provider.dart b/app/lib/providers/goals_provider.dart index f0a917ea..aad2fc40 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -119,6 +119,7 @@ class GoalsProvider extends ChangeNotifier { String? title, GoalScope scope = GoalScope.library, String? scopeId, + List bookIds = const [], int minimumMinutes = 5, String? timezone, String? replaceGoalId, @@ -128,7 +129,16 @@ class GoalsProvider extends ChangeNotifier { final replacing = replaceGoalId == null ? null : store.getReadingGoal(replaceGoalId); if (replaceGoalId != null && replacing == null) throw StateError('This goal no longer exists.'); if (target < 1 || minimumMinutes < 1 || minimumMinutes > 1440) throw ArgumentError('Targets must be positive.'); - if (scope != GoalScope.library && scopeId == null) throw ArgumentError('Choose a book or shelf.'); + final selected = bookIds.toSet().toList()..sort(); + if (selected.length > 1000) throw ArgumentError('Choose up to 1,000 books.'); + if (scope == GoalScope.book && selected.isEmpty && scopeId != null) selected.add(scopeId); + if (scope == GoalScope.book && selected.isEmpty || scope == GoalScope.shelf && scopeId == null) { + throw ArgumentError('Choose books or a shelf.'); + } + if (scope != GoalScope.book && selected.isNotEmpty) throw ArgumentError('Book selection requires a book scope.'); + if (scope == GoalScope.book && type == GoalType.books && target > selected.length) { + throw ArgumentError('Target cannot exceed the number of selected books.'); + } final zone = timezone ?? await GoalCalendar.deviceTimezone(); final created = now; final range = period == GoalPeriod.custom @@ -146,7 +156,8 @@ class GoalsProvider extends ChangeNotifier { title: title?.trim().isEmpty == true ? null : title, timezone: zone, scope: scope, - scopeId: scopeId, + scopeId: scope == GoalScope.book ? selected.first : scopeId, + bookIds: selected.length > 1 ? List.unmodifiable(selected) : const [], minimumMinutes: minimumMinutes, isRecurring: period != GoalPeriod.custom && isRecurring, rules: [GoalRule(at: created, target: target, title: title?.trim().isEmpty == true ? null : title)], @@ -176,6 +187,7 @@ class GoalsProvider extends ChangeNotifier { title: title ?? goal.title, scope: goal.scope, scopeId: goal.scopeId, + bookIds: goal.bookIds, minimumMinutes: goal.minimumMinutes, timezone: goal.timezone, replaceGoalId: goalId, @@ -184,6 +196,12 @@ class GoalsProvider extends ChangeNotifier { return; } if (target != null && target < 1) throw ArgumentError('Target must be positive.'); + if (target != null && + goal.type == GoalType.books && + goal.scope == GoalScope.book && + target > goal.selectedBookIds.length) { + throw ArgumentError('Target cannot exceed the number of selected books.'); + } await _revise(goal, target: target, title: title, repository: repository); } diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index 4eedec4b..10bb8e4d 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:papyrus/widgets/goals/goal_card.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; @@ -9,7 +10,7 @@ import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/goals/goal_controls.dart'; -import 'package:papyrus/widgets/goals/goal_timezone_picker.dart'; +import 'package:papyrus/widgets/shared/searchable_books_field.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; @@ -50,6 +51,7 @@ class _AddGoalSheetState extends State { GoalPeriod _period = GoalPeriod.daily; GoalScope _scope = GoalScope.library; String? _scopeId; + List _bookIds = []; bool _recurring = true; bool _choosingMetric = false; bool _saving = false; @@ -68,6 +70,7 @@ class _AddGoalSheetState extends State { _period = editing.period; _scope = editing.scope; _scopeId = editing.scopeId; + _bookIds = editing.selectedBookIds.toList(); _recurring = editing.isRecurring; _target.text = '${editing.targetValue}'; _title.text = editing.title ?? ''; @@ -92,8 +95,7 @@ class _AddGoalSheetState extends State { if (mounted) { setState(() { _timezoneReady = true; - _error = - 'Could not detect your timezone. Using UTC; choose another timezone in advanced settings if needed.'; + _error = 'Could not detect your timezone. Calendar periods will use UTC.'; }); } }); @@ -106,6 +108,7 @@ class _AddGoalSheetState extends State { _target.text = ['30', '5', '12', '100', '1'][index]; _scope = index == 4 ? GoalScope.book : GoalScope.library; _scopeId = null; + _bookIds = []; _recurring = index != 4; } @@ -132,19 +135,18 @@ class _AddGoalSheetState extends State { void _chooseMetric(GoalType type) => setState(() { _applyPreset({GoalType.books: 2, GoalType.pages: 3, GoalType.minutes: 0, GoalType.days: 1}[type]!); + _period = GoalPeriod.daily; + _target.text = switch (type) { + GoalType.books || GoalType.days => '1', + GoalType.minutes => '30', + GoalType.pages => '100', + }; _choosingMetric = false; }); - void _adjustTarget(int delta) => setState(() { - final value = ((int.tryParse(_target.text) ?? 0) + delta).clamp(1, 100000); - _target.text = '$value'; - _target.selection = TextSelection.collapsed(offset: _target.text.length); - }); - @override Widget build(BuildContext context) { final editing = widget.editing != null; - final texts = Theme.of(context).textTheme; final colors = Theme.of(context).colorScheme; if (_choosingMetric) { return GoalControls( @@ -154,8 +156,6 @@ class _AddGoalSheetState extends State { body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('What would you like to track?', style: texts.titleLarge), - const SizedBox(height: Spacing.lg), for (final type in GoalType.values) Padding( padding: const EdgeInsets.only(bottom: Spacing.sm), @@ -190,16 +190,6 @@ class _AddGoalSheetState extends State { ); } final store = widget.provider.store; - final preview = ReadingGoal( - id: 'preview', - title: _title.text.isEmpty ? null : _title.text, - type: _type, - targetValue: int.tryParse(_target.text) ?? 0, - period: _period, - startDate: DateTime.now(), - endDate: _deadline, - isRecurring: _recurring, - ); final items = _scope == GoalScope.book ? {for (final book in store.books) book.id: book.title} : {for (final shelf in store.shelves) shelf.id: shelf.name}; @@ -213,6 +203,13 @@ class _AddGoalSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + TextFormField( + controller: _title, + enabled: !_saving, + maxLength: 255, + decoration: const InputDecoration(labelText: 'Name (optional)', counterText: ''), + ), + const SizedBox(height: Spacing.md), DropdownButtonFormField( initialValue: _type, key: ValueKey('metric-$_type'), @@ -225,49 +222,33 @@ class _AddGoalSheetState extends State { ? null : (value) => setState(() { _type = value!; + if (_type == GoalType.books && _bookIds.isNotEmpty) _target.text = '${_bookIds.length}'; }), ), - const SizedBox(height: Spacing.lg), - Align( - alignment: AlignmentDirectional.centerStart, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 320), - child: Row( - children: [ - IconButton.outlined( - tooltip: 'Decrease target', - onPressed: _saving ? null : () => _adjustTarget(-1), - icon: const Icon(Icons.remove), - ), - const SizedBox(width: Spacing.sm), - Expanded( - child: TextFormField( - key: const Key('goal-target-input'), - controller: _target, - enabled: !_saving, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - textAlign: TextAlign.center, - style: texts.headlineSmall, - decoration: const InputDecoration(labelText: 'Target'), - validator: _positive, - onChanged: (_) => setState(() {}), - ), - ), - const SizedBox(width: Spacing.sm), - IconButton.outlined( - tooltip: 'Increase target', - onPressed: _saving ? null : () => _adjustTarget(1), - icon: const Icon(Icons.add), - ), - ], - ), + const SizedBox(height: Spacing.md), + _compact( + child: TextFormField( + key: const Key('goal-target-input'), + controller: _target, + enabled: !_saving, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration(labelText: 'Target', suffixText: _typeLabel), + validator: (value) { + final error = _positive(value); + if (error != null) return error; + if (_type == GoalType.books && + _scope == GoalScope.book && + _bookIds.isNotEmpty && + int.parse(value!) > _bookIds.length) { + return 'Choose a target of 1–${_bookIds.length}.'; + } + return null; + }, ), ), - const SizedBox(height: Spacing.sm), - Text(preview.typeLabel, style: texts.bodyMedium), const SizedBox(height: Spacing.lg), - Text('Schedule', style: texts.titleSmall), + Text('Schedule', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: Spacing.sm), Wrap( spacing: Spacing.sm, @@ -315,121 +296,76 @@ class _AddGoalSheetState extends State { ), ), ), - const SizedBox(height: Spacing.lg), - TextFormField( - controller: _title, - enabled: !_saving, - maxLength: 255, - decoration: const InputDecoration(labelText: 'Name (optional)', counterText: ''), - onChanged: (_) => setState(() {}), - ), - if (_scope == GoalScope.book) ...[const SizedBox(height: Spacing.md), _scopeItem(items)], - const SizedBox(height: Spacing.sm), - ExpansionTile( - key: const Key('goal-advanced-settings'), - maintainState: true, - tilePadding: EdgeInsets.zero, - title: const Text('Advanced settings'), - children: [ - DropdownButtonFormField( - initialValue: _scope, - key: ValueKey('scope-$_scope'), - isExpanded: true, - decoration: const InputDecoration(labelText: 'Include'), - items: [ - for (final scope in GoalScope.values) - DropdownMenuItem( - value: scope, - child: Text(switch (scope) { - GoalScope.library => 'Whole library', - GoalScope.book => 'A book', - GoalScope.shelf => 'A shelf', - }), - ), - ], - onChanged: _saving - ? null - : (value) => setState(() { - _scope = value!; - _scopeId = null; - }), - ), - if (_scope == GoalScope.shelf) ...[const SizedBox(height: Spacing.md), _scopeItem(items)], - if (_period != GoalPeriod.custom) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Repeat each period'), - value: _recurring, - onChanged: _saving ? null : (value) => setState(() => _recurring = value), - ), - if (_type == GoalType.days) - Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.md), - child: _compact( - width: 320, - child: TextFormField( - controller: _threshold, - enabled: !_saving, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: 'Minutes to qualify a day', - helperText: 'Time adds up throughout the day.', - ), - validator: (value) { - final n = int.tryParse(value ?? ''); - return n == null || n < 1 || n > 1440 ? 'Enter 1–1,440 minutes.' : null; - }, - onChanged: (_) => setState(() {}), - ), - ), - ), - const SizedBox(height: Spacing.md), - Align( - alignment: AlignmentDirectional.centerStart, - child: OutlinedButton.icon( - key: const Key('goal-timezone-button'), - onPressed: _saving - ? null - : () async { - final zone = await GoalTimezonePicker.show(context, selected: _zone.text); - if (zone != null && mounted) setState(() => _zone.text = zone); - }, - icon: const Icon(Icons.public_outlined), - label: Text('Timezone: ${timezoneCity(_zone.text)}'), + if (_period != GoalPeriod.custom) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Repeat each period'), + value: _recurring, + onChanged: _saving ? null : (value) => setState(() => _recurring = value), + ), + const SizedBox(height: Spacing.md), + DropdownButtonFormField( + initialValue: _scope, + key: ValueKey('scope-$_scope'), + isExpanded: true, + decoration: const InputDecoration(labelText: 'Include'), + items: [ + for (final scope in GoalScope.values) + DropdownMenuItem( + value: scope, + child: Text(switch (scope) { + GoalScope.library => 'Whole library', + GoalScope.book => 'Selected books', + GoalScope.shelf => 'A shelf', + }), ), - ), - const SizedBox(height: Spacing.sm), - const Text('Defaults to your device. Calendar periods use this timezone across your devices.'), - const SizedBox(height: Spacing.md), ], + onChanged: _saving + ? null + : (value) => setState(() { + _scope = value!; + _scopeId = null; + _bookIds = []; + }), ), - const SizedBox(height: Spacing.md), - Text( - '${preview.description}. ${_scope == GoalScope.library ? 'Across your library' : items[_scopeId] ?? 'Choose a book or shelf'}.', - style: texts.bodyLarge, - ), - const SizedBox(height: Spacing.sm), - Text( - editing - ? (_replacementNeeded - ? 'This starts a replacement goal and preserves the original history. Only new reading counts.' - : 'Past periods keep their original target.') - : 'Progress updates from reading and manual logs after you create the goal.', - style: texts.bodySmall, - ), - if (_type == GoalType.days) - Padding( - padding: const EdgeInsets.only(top: Spacing.sm), - child: Text('${_threshold.text} minutes qualifies a reading day.', style: texts.bodySmall), + if (_scope == GoalScope.book) ...[ + const SizedBox(height: Spacing.md), + SearchableBooksField( + key: ValueKey('scope-books-${_bookIds.join(',')}'), + books: store.books, + value: _bookIds, + enabled: !_saving, + onChanged: (value) => setState(() { + _bookIds = value; + _scopeId = value.firstOrNull; + if (_type == GoalType.books && value.isNotEmpty) _target.text = '${value.length}'; + }), ), - if (_type == GoalType.pages) - Padding( - padding: const EdgeInsets.only(top: Spacing.sm), - child: Text( - 'PDF pages are measured. EPUB pages are estimated when page metadata is available.', - style: texts.bodySmall, + ], + if (_scope == GoalScope.shelf) ...[const SizedBox(height: Spacing.md), _scopeItem(items)], + if (_type == GoalType.days) ...[ + const SizedBox(height: Spacing.md), + _compact( + width: 320, + child: TextFormField( + controller: _threshold, + enabled: !_saving, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration(labelText: 'Daily minimum', suffixText: 'minutes'), + onChanged: (_) => setState(() {}), + validator: (value) { + final n = int.tryParse(value ?? ''); + return n == null || n < 1 || n > 1440 ? 'Enter 1–1,440 minutes.' : null; + }, ), ), + ], + if (editing && _replacementNeeded) + const Padding( + padding: EdgeInsets.only(top: Spacing.md), + child: Text('This creates a replacement goal and keeps the original history.'), + ), if (_error != null) Padding( padding: const EdgeInsets.only(top: Spacing.md), @@ -451,6 +387,13 @@ class _AddGoalSheetState extends State { ); } + String get _typeLabel => switch (_type) { + GoalType.books => 'books', + GoalType.pages => 'pages', + GoalType.minutes => 'minutes', + GoalType.days => 'days', + }; + Widget _scopeItem(Map items) => DropdownButtonFormField( initialValue: items.containsKey(_scopeId) ? _scopeId : null, key: ValueKey('scope-item-$_scope-$_scopeId'), @@ -488,7 +431,9 @@ class _AddGoalSheetState extends State { return _type != goal.type || _period != goal.period || _scope != goal.scope || - _scopeId != goal.scopeId || + (_scope == GoalScope.book + ? !setEquals(_bookIds.toSet(), goal.selectedBookIds.toSet()) + : _scopeId != goal.scopeId) || _recurring != goal.isRecurring || zone != goal.timezone || int.tryParse(_threshold.text) != goal.minimumMinutes || @@ -519,6 +464,7 @@ class _AddGoalSheetState extends State { title: _title.text.trim(), scope: _scope, scopeId: _scopeId, + bookIds: _bookIds, minimumMinutes: int.parse(_threshold.text), timezone: _zone.text.trim(), endDate: _period == GoalPeriod.custom ? GoalCalendar.deadline(_deadline, _zone.text.trim()) : null, diff --git a/app/lib/widgets/goals/goal_card.dart b/app/lib/widgets/goals/goal_card.dart index c633230f..f7020b72 100644 --- a/app/lib/widgets/goals/goal_card.dart +++ b/app/lib/widgets/goals/goal_card.dart @@ -11,8 +11,23 @@ IconData goalIcon(GoalType type) => switch (type) { GoalType.minutes => Icons.schedule_outlined, GoalType.days => Icons.calendar_today_outlined, }; -String goalCount(GoalProgress progress) => - '${progress.value} / ${progress.goal.targetValue} ${progress.goal.typeLabel}'; +String goalTime(int seconds) { + if (seconds == 0) return '0 sec'; + final minutes = seconds ~/ 60; + final remainder = seconds % 60; + if (minutes == 0) return '$remainder sec'; + return remainder == 0 ? formatDuration(minutes) : '${formatDuration(minutes)} ${remainder}s'; +} + +String goalCount(GoalProgress progress) => progress.goal.type == GoalType.minutes && progress.seconds % 60 != 0 + ? '${goalTime(progress.seconds)} / ${formatDuration(progress.goal.targetValue)}' + : '${progress.value} / ${progress.goal.targetValue} ${progress.goal.unitLabel(progress.goal.targetValue)}'; + +String goalRemaining(GoalProgress progress) => progress.reached + ? 'Target reached' + : progress.goal.type == GoalType.minutes + ? '${goalTime((progress.goal.targetValue * 60 - progress.seconds).clamp(0, progress.goal.targetValue * 60))} to go' + : progress.projected.statusText; class GoalCard extends StatelessWidget { const GoalCard({ @@ -20,23 +35,19 @@ class GoalCard extends StatelessWidget { required this.goal, this.progress, this.onTap, - this.onContinue, this.onMenu, this.scopeLabel, this.isDesktop = false, this.fillHeight = false, - this.bookProgressLabel, this.showReadingDays = false, }); final ReadingGoal goal; final GoalProgress? progress; final VoidCallback? onTap; - final VoidCallback? onContinue; final void Function(String)? onMenu; final String? scopeLabel; final bool isDesktop; final bool fillHeight; - final String? bookProgressLabel; final bool showReadingDays; @override Widget build(BuildContext context) { @@ -113,7 +124,9 @@ class GoalCard extends StatelessWidget { ), const SizedBox(height: Spacing.md), Text( - '${projected.currentValue} / ${projected.targetValue} ${projected.typeLabel}', + progress == null + ? '${projected.currentValue} / ${projected.targetValue} ${projected.unitLabel(projected.targetValue)}' + : goalCount(progress!), style: texts.titleMedium, ), const SizedBox(height: Spacing.xs), @@ -127,7 +140,7 @@ class GoalCard extends StatelessWidget { Semantics( container: true, label: - '${projected.displayTitle}, ${projected.currentValue} of ${projected.targetValue} ${projected.typeLabel}', + '${projected.displayTitle}, ${progress == null ? '${projected.currentValue} of ${projected.targetValue} ${projected.unitLabel(projected.targetValue)}' : goalCount(progress!)}', child: LinearProgressIndicator( value: progress?.fraction ?? projected.progress, minHeight: 6, @@ -162,7 +175,8 @@ class GoalCard extends StatelessWidget { '${((progress?.fraction ?? projected.progress) * 100).round()}% complete', style: texts.bodySmall, ), - if (!projected.isCompleted) Text(projected.statusText, style: texts.bodySmall), + if (!projected.isCompleted) + Text(progress == null ? projected.statusText : goalRemaining(progress!), style: texts.bodySmall), ], ), const SizedBox(height: Spacing.xs), @@ -178,10 +192,6 @@ class GoalCard extends StatelessWidget { ), if (progress?.estimated == true && goal.type == GoalType.pages) Text('Estimated pages included', style: texts.bodySmall), - if (bookProgressLabel != null) ...[ - const SizedBox(height: Spacing.sm), - Text(bookProgressLabel!, style: texts.bodySmall), - ], if (showReadingDays && goal.type == GoalType.days) ...[ const SizedBox(height: Spacing.md), if (goal.period == GoalPeriod.weekly && progress != null) @@ -194,18 +204,6 @@ class GoalCard extends StatelessWidget { Text('${goal.minimumMinutes} minutes qualifies a day', style: texts.bodySmall), ], if (fillHeight) const Spacer(), - if (onContinue != null && !expired && !goal.isArchived && goal.isActive) - Padding( - padding: const EdgeInsets.only(top: Spacing.sm), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: onContinue, - icon: const Icon(Icons.play_arrow_outlined), - label: const Text('Continue reading'), - ), - ), - ), ], ), ), diff --git a/app/lib/widgets/goals/goal_details_sheet.dart b/app/lib/widgets/goals/goal_details_sheet.dart index c27280cf..a921877c 100644 --- a/app/lib/widgets/goals/goal_details_sheet.dart +++ b/app/lib/widgets/goals/goal_details_sheet.dart @@ -64,10 +64,9 @@ class _GoalDetailsSheetState extends State { body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - GoalCard(goal: goal, progress: progress, showReadingDays: true), + _progressSummary(context, progress), const SizedBox(height: Spacing.lg), if (widget.historical == null && !goal.isArchived) ...[ - const SizedBox(height: Spacing.md), Wrap( spacing: Spacing.sm, runSpacing: Spacing.sm, @@ -110,12 +109,32 @@ class _GoalDetailsSheetState extends State { ), ), const SizedBox(height: Spacing.lg), - Text('Counted activity', style: Theme.of(context).textTheme.titleMedium), + Text('Reading activity', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: Spacing.sm), if (progress.activities.isEmpty) - const Text('No qualifying reading in this period yet.') - else - ReadingActivityList(activities: groupReadingActivities(progress.activities), provider: widget.provider), + const Text('No reading activity for this goal yet.') + else ...[ + if (goal.type == GoalType.minutes || goal.type == GoalType.days) ...[ + if (progress.hasCreationCutoff || progress.hasOverlappingTime) + Text( + [ + if (progress.hasCreationCutoff) 'Time before goal creation is excluded.', + if (progress.hasOverlappingTime) 'Overlapping time is counted once.', + ].join(' '), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ReadingActivityList( + activities: groupReadingActivities(progress.activities), + provider: widget.provider, + contributionLabel: goal.type == GoalType.minutes || goal.type == GoalType.days + ? (activity) => + activity.kind == 'reading' && progress.eligibleSecondsFor(activity) != activity.seconds + ? '${goalTime(progress.eligibleSecondsFor(activity))} for this goal' + : null + : null, + ), + ], if (periods.isNotEmpty && widget.historical == null) ...[ const SizedBox(height: Spacing.lg), Text('Previous periods', style: Theme.of(context).textTheme.titleMedium), @@ -135,22 +154,13 @@ class _GoalDetailsSheetState extends State { ), ), ], - ExpansionTile( - tilePadding: EdgeInsets.zero, - title: const Text('Calendar settings'), - children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Text('Timezone: ${goal.timezone}', style: Theme.of(context).textTheme.bodySmall), - ), - ], - ), if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], ), footer: BottomSheetFormActions( + equalWidths: true, onCancel: _saving || widget.provider.store.getReadingGoal(goal.id) == null ? null : _delete, - cancelLabel: 'Delete goal', + cancelLabel: 'Delete', onSave: _saving ? null : () => Navigator.pop(context), saveLabel: 'Done', ), @@ -158,6 +168,122 @@ class _GoalDetailsSheetState extends State { ); }, ); + Widget _progressSummary(BuildContext context, GoalProgress progress) { + final goal = progress.projected; + final texts = Theme.of(context).textTheme; + final colors = Theme.of(context).colorScheme; + final ended = !progress.range.end.isAfter(widget.provider.now); + final status = goal.isArchived + ? 'Archived' + : !goal.isActive + ? 'Paused' + : ended + ? (progress.reached ? 'Achieved' : 'Missed') + : goalRemaining(progress); + final scope = switch (goal.scope) { + GoalScope.library => 'Whole library', + GoalScope.book => + goal.selectedBookIds.length > 1 + ? goal.selectedBookIds.map((id) => widget.provider.store.getBook(id)?.title ?? 'Removed book').join(', ') + : widget.provider.store.getBook(goal.scopeId!)?.title ?? 'Removed book', + GoalScope.shelf => widget.provider.store.getShelf(goal.scopeId!)?.name ?? 'Removed shelf', + }; + final start = GoalCalendar.local(progress.range.start, goal.timezone); + final end = GoalCalendar.local(progress.range.end.subtract(const Duration(microseconds: 1)), goal.timezone); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(goalIcon(goal.type), color: colors.primary), + const SizedBox(width: Spacing.sm), + Expanded(child: Text(goal.displayTitle, style: texts.titleLarge)), + ], + ), + const SizedBox(height: Spacing.md), + Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + Text(goalCount(progress), style: texts.headlineSmall), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (progress.reached || !goal.isActive || goal.isArchived || ended) ...[ + Icon( + goal.isArchived + ? Icons.archive_outlined + : !goal.isActive + ? Icons.pause_circle_outline + : progress.reached + ? Icons.check_circle_outline + : Icons.event_outlined, + size: IconSizes.small, + ), + const SizedBox(width: Spacing.xs), + ], + Flexible(child: Text(status, style: texts.bodyMedium)), + ], + ), + ], + ), + const SizedBox(height: Spacing.sm), + LinearProgressIndicator( + value: progress.fraction, + minHeight: 6, + borderRadius: BorderRadius.circular(AppRadius.full), + semanticsLabel: goal.displayTitle, + semanticsValue: '${(progress.fraction * 100).round()}', + ), + const SizedBox(height: Spacing.md), + Text(scope, style: texts.bodyMedium?.copyWith(color: colors.onSurfaceVariant)), + const SizedBox(height: Spacing.xs), + Text( + '${DateFormat.yMMMd().format(start)} – ${DateFormat.yMMMd().format(end)}', + style: texts.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + if (progress.estimated && goal.type == GoalType.pages) ...[ + const SizedBox(height: Spacing.xs), + Text('Estimated pages included', style: texts.bodySmall), + ], + if (goal.type == GoalType.days) ...[ + const SizedBox(height: Spacing.md), + if (goal.period == GoalPeriod.weekly) + Wrap( + spacing: Spacing.lg, + runSpacing: Spacing.sm, + children: [ + for (var i = 0; i < 7; i++) + _readingDay(context, progress, GoalCalendar.dayOffset(progress.range.start, i, goal.timezone)), + ], + ), + const SizedBox(height: Spacing.sm), + Text('Daily minimum: ${goal.minimumMinutes} minutes', style: texts.bodySmall), + ], + ], + ); + } + + Widget _readingDay(BuildContext context, GoalProgress progress, DateTime day) { + final date = GoalCalendar.local(day, progress.goal.timezone); + final qualified = progress.qualifiedDays.contains(day); + return Semantics( + label: '${DateFormat.yMMMd().format(date)}: ${qualified ? 'Reading day' : 'Daily minimum not reached'}', + excludeSemantics: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(DateFormat.E().format(date), style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: Spacing.xs), + Icon(qualified ? Icons.check : Icons.remove, size: IconSizes.small), + ], + ), + ); + } + Future _action(Future Function() action) async { setState(() { _saving = true; @@ -173,7 +299,15 @@ class _GoalDetailsSheetState extends State { } Future _delete() async { - final confirmed = await showDialog( + final confirmed = await confirmGoalDeletion(context); + if (confirmed != true || !mounted) return; + await _action(() => widget.provider.deleteGoal(widget.goal.id, repository: _repository)); + if (mounted && _error == null) Navigator.pop(context); + } +} + +Future confirmGoalDeletion(BuildContext context) async => + await showDialog( context: context, animationStyle: AppMotion.animationStyle(context), builder: (context) => AlertDialog( @@ -181,12 +315,8 @@ class _GoalDetailsSheetState extends State { content: const Text('Reading activity and previous periods remain in your history.'), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), - FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete goal')), + FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete')), ], ), - ); - if (confirmed != true || !mounted) return; - await _action(() => widget.provider.deleteGoal(widget.goal.id, repository: _repository)); - if (mounted && _error == null) Navigator.pop(context); - } -} + ) ?? + false; diff --git a/app/lib/widgets/goals/log_reading_sheet.dart b/app/lib/widgets/goals/log_reading_sheet.dart index fe6d8f9d..397d2ab4 100644 --- a/app/lib/widgets/goals/log_reading_sheet.dart +++ b/app/lib/widgets/goals/log_reading_sheet.dart @@ -7,8 +7,11 @@ import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_motion.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/goals/goal_controls.dart'; +import 'package:papyrus/widgets/book_details/book_cover_image.dart'; +import 'package:papyrus/widgets/shared/bottom_sheet_actions.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; import 'package:papyrus/widgets/shared/app_date_picker.dart'; +import 'package:papyrus/widgets/shared/searchable_book_field.dart'; class LogReadingSheet extends StatefulWidget { const LogReadingSheet({super.key, required this.provider, this.book, this.correcting}); @@ -86,64 +89,71 @@ class _LogReadingSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - DropdownButtonFormField( - initialValue: books.any((book) => book.id == _bookId) ? _bookId : null, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Book'), - items: [ - for (final book in books) - DropdownMenuItem( - value: book.id, - child: Text(book.title, overflow: TextOverflow.ellipsis), - ), - ], - validator: (value) => value == null ? 'Choose a book.' : null, - onChanged: _saving || widget.correcting != null ? null : (value) => setState(() => _bookId = value), - ), + if (widget.correcting != null) + _ReadingBookHeader( + book: widget.provider.store.getBook(_bookId!), + fallbackTitle: widget.correcting!.bookTitle, + ) + else + SearchableBookField( + key: ValueKey('log-book-$_bookId'), + books: books, + value: _bookId, + enabled: !_saving, + onChanged: (value) => setState(() => _bookId = value), + ), if (books.isEmpty) const Padding( padding: EdgeInsets.only(top: Spacing.sm), child: Text('Add a digital or physical book to your library before logging reading.'), ), const SizedBox(height: Spacing.lg), - Text('Session ends at', style: Theme.of(context).textTheme.bodySmall), - const SizedBox(height: Spacing.sm), Wrap( spacing: Spacing.md, runSpacing: Spacing.sm, children: [ - OutlinedButton.icon( - onPressed: _saving - ? null - : () async { - final date = await showAppDatePicker( - context: context, - initialDate: _end, - firstDate: DateTime(1900), - lastDate: DateTime.now(), - ); - if (date != null && mounted) { - setState(() => _end = DateTime(date.year, date.month, date.day, _end.hour, _end.minute)); - } - }, - icon: const Icon(Icons.event_outlined), - label: Text(DateFormat.yMMMd().format(_end)), + _dateControl( + label: 'Date', + child: OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final date = await showAppDatePicker( + context: context, + initialDate: _end, + firstDate: DateTime(1900), + lastDate: DateTime.now(), + ); + if (date != null && mounted) { + setState( + () => _end = DateTime(date.year, date.month, date.day, _end.hour, _end.minute), + ); + } + }, + icon: const Icon(Icons.event_outlined), + label: Text(DateFormat.yMMMd().format(_end)), + ), ), - OutlinedButton.icon( - onPressed: _saving - ? null - : () async { - final time = await showDialog( - context: context, - animationStyle: AppMotion.animationStyle(context), - builder: (_) => TimePickerDialog(initialTime: TimeOfDay.fromDateTime(_end)), - ); - if (time != null && mounted) { - setState(() => _end = DateTime(_end.year, _end.month, _end.day, time.hour, time.minute)); - } - }, - icon: const Icon(Icons.schedule), - label: Text(DateFormat.Hm().format(_end)), + _dateControl( + label: 'Finished at', + child: OutlinedButton.icon( + onPressed: _saving + ? null + : () async { + final time = await showDialog( + context: context, + animationStyle: AppMotion.animationStyle(context), + builder: (_) => TimePickerDialog(initialTime: TimeOfDay.fromDateTime(_end)), + ); + if (time != null && mounted) { + setState( + () => _end = DateTime(_end.year, _end.month, _end.day, time.hour, time.minute), + ); + } + }, + icon: const Icon(Icons.schedule), + label: Text(DateFormat.Hm().format(_end)), + ), ), ], ), @@ -193,17 +203,10 @@ class _LogReadingSheetState extends State { controller: _note, maxLength: 10000, maxLines: 3, - decoration: const InputDecoration(labelText: 'Note (optional)'), + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration(labelText: 'Note (optional)', alignLabelWithHint: true), ), - const SizedBox(height: Spacing.md), - const Text( - 'Manual entries count toward matching goals. Reading before a goal was created stays in your history but does not count toward that goal.', - ), - if (widget.correcting != null) - const Padding( - padding: EdgeInsets.only(top: Spacing.md), - child: Text('The original entry is retained as corrected in your activity history.'), - ), + if (_error != null) Padding( padding: const EdgeInsets.only(top: Spacing.md), @@ -221,6 +224,16 @@ class _LogReadingSheetState extends State { ); } + Widget _dateControl({required String label, required Widget child}) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: Spacing.sm), + child, + ], + ); + Future _save() async { if (!_form.currentState!.validate()) return; final book = widget.provider.store.getBook(_bookId!); @@ -255,6 +268,48 @@ class _LogReadingSheetState extends State { } } +/// Plain book context for entries whose book identity cannot be changed. +class _ReadingBookHeader extends StatelessWidget { + const _ReadingBookHeader({required this.book, required this.fallbackTitle, this.details = const []}); + final Book? book; + final String fallbackTitle; + final List details; + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ExcludeSemantics( + child: CoverImagePreview( + bookId: book?.id, + imageUrl: book?.coverUrl, + mediaId: book?.coverMediaId, + size: BookCoverSize.listThumbnail, + ), + ), + const SizedBox(width: Spacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(book?.title ?? fallbackTitle, style: Theme.of(context).textTheme.titleLarge), + if (book != null && book!.allAuthors.trim().isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + Text( + book!.allAuthors, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ...details, + ], + ), + ), + ], + ); +} + /// Book titles and dates belong to groups; individual entries retain their audit actions. class ReadingActivityList extends StatelessWidget { const ReadingActivityList({ @@ -262,7 +317,9 @@ class ReadingActivityList extends StatelessWidget { required this.activities, required this.provider, this.correctedIds = const {}, + this.contributionLabel, }); + final String? Function(ReadingActivity)? contributionLabel; final List activities; final GoalsProvider provider; final Set correctedIds; @@ -307,6 +364,7 @@ class ReadingActivityList extends StatelessWidget { provider: provider, corrected: correctedIds.contains(activity.id), showBookTitle: false, + contributionLabel: contributionLabel?.call(activity), ), ], ], @@ -321,7 +379,9 @@ class ReadingActivityTile extends StatelessWidget { required this.provider, this.corrected = false, this.showBookTitle = true, + this.contributionLabel, }); + final String? contributionLabel; final ReadingActivity activity; final GoalsProvider provider; final bool corrected; @@ -359,7 +419,9 @@ class ReadingActivityTile extends StatelessWidget { ? Text( '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}\n${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', ) - : null, + : contributionLabel == null + ? null + : Text(contributionLabel!), trailing: showBookTitle ? null : Text(DateFormat.Hm().format(activity.startTime.toLocal()), style: Theme.of(context).textTheme.bodySmall), @@ -404,74 +466,102 @@ class _ActivityDetailsState extends State<_ActivityDetails> { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(activity.bookTitle, style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: Spacing.md), - Text( - '${activity.source == 'reader' ? 'Recorded by the reader' : 'Logged manually'} · ${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', - ), - const SizedBox(height: Spacing.md), - if (activity.kind == 'reading') - Text( - '${activity.seconds ~/ 60} minutes · ${activity.pages > 0 - ? '${activity.pages} manual pages' - : activity.isEstimated - ? 'Estimated EPUB coverage' - : activity.coverage.isEmpty - ? 'No pages logged' - : 'PDF coverage'}', - ), - if (activity.kind == 'completion') const Text('Explicitly marked as finished.'), - if (activity.note != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: Text(activity.note!), - ), - if (widget.corrected) - const Padding( - padding: EdgeInsets.only(top: Spacing.md), - child: Text('This entry was corrected and no longer contributes.'), - ), - if (book == null) - const Padding( - padding: EdgeInsets.only(top: Spacing.md), - child: Text('The book was removed. Its activity history is retained.'), - ), - if (!widget.corrected && activity.source == 'manual' && book != null) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: OutlinedButton.icon( - onPressed: _saving - ? null - : () { - final parent = Navigator.of(context, rootNavigator: true).context; - Navigator.pop(context); - LogReadingSheet.show(parent, provider: widget.provider, book: book, correcting: activity); - }, - icon: const Icon(Icons.edit_outlined), - label: const Text('Correct entry'), - ), - ), - if (!widget.corrected) - Padding( - padding: const EdgeInsets.only(top: Spacing.md), - child: OutlinedButton.icon( - onPressed: _saving ? null : _undo, - icon: const Icon(Icons.undo), - label: const Text('Undo this entry'), + _ReadingBookHeader( + book: book, + fallbackTitle: activity.bookTitle, + details: [ + const SizedBox(height: Spacing.sm), + if (activity.kind == 'completion') + _detailLine(context, Icons.check_circle_outline, 'Finished book') + else if (activity.kind == 'reversal') + _detailLine(context, Icons.undo, 'Entry undone') + else + Text(_readingSummary(activity), style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: Spacing.xs), + Text( + '${DateFormat.yMMMd().add_Hm().format(activity.endTime.toLocal())} · ${activity.source == 'reader' ? 'Reader' : 'Manual entry'}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), + ], + ), + if (activity.note?.trim().isNotEmpty == true) ...[ + const SizedBox(height: Spacing.lg), + Text('Note', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: Spacing.xs), + Text(activity.note!), + ], + if (widget.corrected) ...[ + const SizedBox(height: Spacing.lg), + _detailLine(context, Icons.history, 'This entry was corrected and no longer contributes.'), + ], + if (book == null) ...[ + const SizedBox(height: Spacing.md), + Text('This book is no longer in your library.', style: Theme.of(context).textTheme.bodySmall), + ], + if (!widget.corrected && activity.kind != 'reversal') ...[ + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + if (activity.source == 'manual' && book != null) + OutlinedButton.icon( + onPressed: _saving + ? null + : () { + final parent = Navigator.of(context, rootNavigator: true).context; + Navigator.pop(context); + LogReadingSheet.show(parent, provider: widget.provider, book: book, correcting: activity); + }, + icon: const Icon(Icons.edit_outlined), + label: const Text('Correct entry'), + ), + OutlinedButton.icon( + onPressed: _saving ? null : _undo, + icon: const Icon(Icons.undo), + label: const Text('Undo this entry'), + ), + ], ), + ], if (_error != null) Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], ), - footer: BottomSheetFormActions( - onCancel: null, - onSave: _saving ? null : () => Navigator.pop(context), - saveLabel: 'Done', + footer: BottomSheetActions( + primary: FilledButton(onPressed: _saving ? null : () => Navigator.pop(context), child: const Text('Done')), ), ), ); } + String _duration(int seconds) { + if (seconds < 60) return '$seconds sec'; + final hours = seconds ~/ 3600; + final minutes = (seconds % 3600) ~/ 60; + final remainder = seconds % 60; + return [if (hours > 0) '${hours}h', if (minutes > 0) '${minutes}m', if (remainder > 0) '${remainder}s'].join(' '); + } + + String _readingSummary(ReadingActivity activity) { + final parts = [ + if (activity.seconds > 0) _duration(activity.seconds), + if (activity.pages > 0) '${activity.pages} ${activity.pages == 1 ? 'page' : 'pages'}', + if (activity.coverage.isNotEmpty) activity.isEstimated ? 'Estimated EPUB pages' : 'PDF pages', + ]; + return parts.isEmpty ? 'No reading time or pages recorded.' : parts.join(' · '); + } + + Widget _detailLine(BuildContext context, IconData icon, String label) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: IconSizes.small, color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(width: Spacing.xs), + Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), + ], + ); + Future _undo() async { final confirmed = await showDialog( context: context, diff --git a/app/lib/widgets/goals/reading_activity_timeline.dart b/app/lib/widgets/goals/reading_activity_timeline.dart index b0547590..b2cf6606 100644 --- a/app/lib/widgets/goals/reading_activity_timeline.dart +++ b/app/lib/widgets/goals/reading_activity_timeline.dart @@ -119,7 +119,8 @@ class ReadingActivityTimeline extends StatelessWidget { ...groupReadingActivities(entries.where((entry) => correctedIds.contains(entry.id))), ]; return ExpansionTile( - key: ValueKey('activity-book-${entries.first.bookId}-${day.toIso8601String()}'), + // Expansion state must not share the enclosing list's saved scroll offset. + key: PageStorageKey('activity-book-${entries.first.bookId}-${day.toIso8601String()}'), tilePadding: const EdgeInsets.all(Spacing.md), childrenPadding: const EdgeInsets.symmetric(horizontal: Spacing.md), shape: const Border(), diff --git a/app/lib/widgets/shared/app_bottom_sheet.dart b/app/lib/widgets/shared/app_bottom_sheet.dart index 6651d4de..7fe6d1f9 100644 --- a/app/lib/widgets/shared/app_bottom_sheet.dart +++ b/app/lib/widgets/shared/app_bottom_sheet.dart @@ -141,6 +141,7 @@ class BottomSheetFormActions extends StatelessWidget { this.saveLabel = 'Save', this.cancelLabel = 'Cancel', this.saveButtonKey, + this.equalWidths = false, }); final VoidCallback? onCancel; @@ -148,9 +149,11 @@ class BottomSheetFormActions extends StatelessWidget { final String saveLabel; final String cancelLabel; final Key? saveButtonKey; + final bool equalWidths; @override Widget build(BuildContext context) => BottomSheetActions( + equalWidths: equalWidths, secondary: OutlinedButton(onPressed: onCancel, child: Text(cancelLabel)), primary: FilledButton(key: saveButtonKey, onPressed: onSave, child: Text(saveLabel)), ); diff --git a/app/lib/widgets/shared/bottom_sheet_actions.dart b/app/lib/widgets/shared/bottom_sheet_actions.dart index cfc5782f..e168f0a6 100644 --- a/app/lib/widgets/shared/bottom_sheet_actions.dart +++ b/app/lib/widgets/shared/bottom_sheet_actions.dart @@ -3,11 +3,14 @@ import 'package:papyrus/themes/design_tokens.dart'; /// Matching sheet actions arranged in a single row on every screen size. class BottomSheetActions extends StatelessWidget { - const BottomSheetActions({super.key, required this.primary, this.secondary}); + const BottomSheetActions({super.key, required this.primary, this.secondary, this.equalWidths = false}); final Widget primary; final Widget? secondary; + /// Give longer secondary labels room on compact screens. + final bool equalWidths; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -39,7 +42,10 @@ class BottomSheetActions extends StatelessWidget { if (compact) Expanded(child: secondary!) else secondary!, SizedBox(width: compact ? Spacing.sm : Spacing.md), ], - if (compact) Expanded(flex: secondary == null || enlargedText ? 1 : 2, child: primary) else primary, + if (compact) + Expanded(flex: secondary == null || enlargedText || equalWidths ? 1 : 2, child: primary) + else + primary, ], ), ), diff --git a/app/lib/widgets/shared/searchable_book_field.dart b/app/lib/widgets/shared/searchable_book_field.dart new file mode 100644 index 00000000..4a8fb621 --- /dev/null +++ b/app/lib/widgets/shared/searchable_book_field.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; + +/// A validated library selection, with title, author and ISBN search. +class SearchableBookField extends FormField { + SearchableBookField({ + super.key, + required List books, + String? value, + super.enabled = true, + required ValueChanged onChanged, + }) : super( + initialValue: value, + validator: (value) => books.any((book) => book.id == value) ? null : 'Choose a book.', + builder: (field) { + final selected = books.where((book) => book.id == field.value).firstOrNull; + return InkWell( + onTap: !enabled || books.isEmpty + ? null + : () async { + final book = await showModalBottomSheet( + context: field.context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + constraints: const BoxConstraints(maxWidth: 640), + sheetAnimationStyle: AppMotion.animationStyle(field.context), + builder: (_) => _BookSearchSheet(books: books, selectedId: field.value), + ); + if (book != null && field.mounted) { + field.didChange(book.id); + onChanged(book.id); + } + }, + child: InputDecorator( + isEmpty: selected == null, + decoration: InputDecoration( + labelText: 'Book', + errorText: field.errorText, + suffixIcon: const Icon(Icons.search), + ), + child: Text(selected?.title ?? '', maxLines: 2, overflow: TextOverflow.ellipsis), + ), + ); + }, + ); +} + +class _BookSearchSheet extends StatefulWidget { + const _BookSearchSheet({required this.books, this.selectedId}); + final List books; + final String? selectedId; + @override + State<_BookSearchSheet> createState() => _BookSearchSheetState(); +} + +class _BookSearchSheetState extends State<_BookSearchSheet> { + String _query = ''; + @override + Widget build(BuildContext context) { + final query = _query.trim().toLowerCase(); + final books = widget.books + .where( + (book) => + '${book.title} ${book.allAuthors} ${book.isbn ?? ''} ${book.isbn13 ?? ''}'.toLowerCase().contains(query), + ) + .toList(); + return AppBottomSheet( + title: 'Choose a book', + onClose: () => Navigator.pop(context), + expandBody: true, + scrollable: false, + expandOnScroll: false, + body: Column( + children: [ + TextField( + autofocus: true, + decoration: const InputDecoration(hintText: 'Search books', prefixIcon: Icon(Icons.search)), + onChanged: (value) => setState(() => _query = value), + ), + const SizedBox(height: Spacing.md), + Expanded( + child: books.isEmpty + ? const Align(alignment: Alignment.topCenter, child: Text('No books found')) + : ListView.builder( + itemCount: books.length, + itemBuilder: (context, index) { + final book = books[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: book.allAuthors.isEmpty + ? null + : Text(book.allAuthors, maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: book.id == widget.selectedId ? const Icon(Icons.check) : null, + onTap: () => Navigator.pop(context, book), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/widgets/shared/searchable_books_field.dart b/app/lib/widgets/shared/searchable_books_field.dart new file mode 100644 index 00000000..977b5afb --- /dev/null +++ b/app/lib/widgets/shared/searchable_books_field.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; + +/// A searchable library selection that retains unavailable books when editing. +class SearchableBooksField extends FormField> { + SearchableBooksField({ + super.key, + required List books, + required List value, + super.enabled = true, + required ValueChanged> onChanged, + }) : super( + initialValue: value, + validator: (value) => value == null || value.isEmpty ? 'Choose at least one book.' : null, + builder: (field) { + final selected = field.value ?? const []; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.search), + label: Text(selected.isEmpty ? 'Choose books' : 'Change books (${selected.length})'), + onPressed: !enabled || books.isEmpty + ? null + : () async { + final result = await showModalBottomSheet>( + context: field.context, + useRootNavigator: true, + useSafeArea: true, + isScrollControlled: true, + constraints: const BoxConstraints(maxWidth: 640), + sheetAnimationStyle: AppMotion.animationStyle(field.context), + builder: (_) => _BooksSearchSheet(books: books, selected: selected), + ); + if (result != null && field.mounted) { + field.didChange(result); + onChanged(result); + } + }, + ), + if (selected.isNotEmpty) ...[ + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + for (final id in selected) + Chip( + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Text( + books.where((book) => book.id == id).firstOrNull?.title ?? 'Removed book', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + onDeleted: !enabled + ? null + : () { + final result = selected.where((value) => value != id).toList(); + field.didChange(result); + onChanged(result); + }, + ), + ], + ), + ], + if (books.isEmpty || field.hasError) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Text( + field.errorText ?? 'Your library has no books to select.', + style: TextStyle(color: Theme.of(field.context).colorScheme.error), + ), + ), + ], + ); + }, + ); +} + +class _BooksSearchSheet extends StatefulWidget { + const _BooksSearchSheet({required this.books, required this.selected}); + final List books; + final List selected; + @override + State<_BooksSearchSheet> createState() => _BooksSearchSheetState(); +} + +class _BooksSearchSheetState extends State<_BooksSearchSheet> { + late final Set _selected = widget.selected.toSet(); + String _query = ''; + @override + Widget build(BuildContext context) { + final query = _query.trim().toLowerCase(); + final books = widget.books + .where( + (book) => + '${book.title} ${book.allAuthors} ${book.isbn ?? ''} ${book.isbn13 ?? ''}'.toLowerCase().contains(query), + ) + .toList(); + return AppBottomSheet( + title: 'Choose books', + onClose: () => Navigator.pop(context), + expandBody: true, + scrollable: false, + expandOnScroll: false, + body: Column( + children: [ + TextField( + autofocus: true, + decoration: const InputDecoration(hintText: 'Search books', prefixIcon: Icon(Icons.search)), + onChanged: (value) => setState(() => _query = value), + ), + const SizedBox(height: Spacing.md), + Expanded( + child: books.isEmpty + ? const Align(alignment: Alignment.topCenter, child: Text('No books found')) + : ListView.builder( + itemCount: books.length, + itemBuilder: (context, index) { + final book = books[index]; + return CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: _selected.contains(book.id), + title: Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: book.allAuthors.isEmpty + ? null + : Text(book.allAuthors, maxLines: 1, overflow: TextOverflow.ellipsis), + onChanged: (value) => setState(() { + if (value == true) { + _selected.add(book.id); + } else { + _selected.remove(book.id); + } + }), + ); + }, + ), + ), + ], + ), + footer: BottomSheetFormActions( + onCancel: () => Navigator.pop(context), + onSave: _selected.isEmpty ? null : () => Navigator.pop(context, _selected.toList()..sort()), + saveLabel: 'Select (${_selected.length})', + ), + ); + } +} diff --git a/app/test/goals/fixtures/projections.json b/app/test/goals/fixtures/projections.json index 36d8a8d3..70806220 100644 --- a/app/test/goals/fixtures/projections.json +++ b/app/test/goals/fixtures/projections.json @@ -558,5 +558,409 @@ "days": 1, "current_value": 1 } + }, + { + "name": "multiple selected books reading_time", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000021", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:07:00Z", + "created_at": "2026-10-05T10:07:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 2, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000022", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:07:00Z", + "end_time": "2026-10-05T10:11:00Z", + "created_at": "2026-10-05T10:11:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 3, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000023", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:11:00Z", + "end_time": "2026-10-05T11:50:00Z", + "created_at": "2026-10-05T11:50:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 99, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 5, + "finished_books": 0, + "days": 1, + "current_value": 10 + } + }, + { + "name": "multiple selected books pages_count", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "pages_count", + "target_value": 10, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000021", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:07:00Z", + "created_at": "2026-10-05T10:07:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 2, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000022", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:07:00Z", + "end_time": "2026-10-05T10:11:00Z", + "created_at": "2026-10-05T10:11:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 3, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000023", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:11:00Z", + "end_time": "2026-10-05T11:50:00Z", + "created_at": "2026-10-05T11:50:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 99, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 5, + "finished_books": 0, + "days": 1, + "current_value": 5 + } + }, + { + "name": "multiple selected books reading_days", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_days", + "target_value": 1, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000021", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:07:00Z", + "created_at": "2026-10-05T10:07:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 2, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000022", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:07:00Z", + "end_time": "2026-10-05T10:11:00Z", + "created_at": "2026-10-05T10:11:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 3, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000023", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:11:00Z", + "end_time": "2026-10-05T11:50:00Z", + "created_at": "2026-10-05T11:50:00Z", + "kind": "reading", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 99, + "coverage": [] + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 600, + "pages": 5, + "finished_books": 0, + "days": 1, + "current_value": 1 + } + }, + { + "name": "selected book completions deduplicate and undo", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "books_count", + "target_value": 2, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T10:00:00Z", + "timezone": "UTC", + "scope": "book", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [], + "scope_id": "00000000-0000-0000-0000-000000000002", + "book_ids": [ + "00000000-0000-0000-0000-000000000002", + "00000000-0000-0000-0000-000000000003" + ] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-000000000031", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:01:00Z", + "end_time": "2026-10-05T10:01:00Z", + "created_at": "2026-10-05T10:01:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000032", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:02:00Z", + "end_time": "2026-10-05T10:02:00Z", + "created_at": "2026-10-05T10:02:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000033", + "book_id": "00000000-0000-0000-0000-000000000004", + "book_title": "Book", + "start_time": "2026-10-05T10:03:00Z", + "end_time": "2026-10-05T10:03:00Z", + "created_at": "2026-10-05T10:03:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000034", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T10:04:00Z", + "end_time": "2026-10-05T10:04:00Z", + "created_at": "2026-10-05T10:04:00Z", + "kind": "completion", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-000000000035", + "book_id": "00000000-0000-0000-0000-000000000003", + "book_title": "Book", + "start_time": "2026-10-05T10:05:00Z", + "end_time": "2026-10-05T10:05:00Z", + "created_at": "2026-10-05T10:05:00Z", + "kind": "reversal", + "source": "manual", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [], + "correction_of": "00000000-0000-0000-0000-000000000032" + } + ], + "now": "2026-10-06T00:00:00Z", + "expected": { + "seconds": 0, + "pages": 0, + "finished_books": 1, + "days": 0, + "current_value": 1 + } + }, + { + "name": "identical manual sessions only count once after goal creation", + "goal": { + "id": "00000000-0000-0000-0000-000000000001", + "goal_type": "reading_time", + "target_value": 30, + "time_period": "custom", + "start_date": "2026-10-05T00:00:00Z", + "end_date": "2026-10-06T00:00:00Z", + "created_at": "2026-10-05T00:35:30Z", + "timezone": "UTC", + "scope": "library", + "minimum_minutes": 5, + "is_recurring": false, + "is_active": true, + "is_archived": false, + "rules": [] + }, + "activities": [ + { + "id": "00000000-0000-0000-0000-00000000000a", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T00:06:00Z", + "end_time": "2026-10-05T00:36:00Z", + "created_at": "2026-10-05T00:36:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000b", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T00:06:00Z", + "end_time": "2026-10-05T00:36:00Z", + "created_at": "2026-10-05T00:36:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + }, + { + "id": "00000000-0000-0000-0000-00000000000c", + "book_id": "00000000-0000-0000-0000-000000000002", + "book_title": "Book", + "start_time": "2026-10-05T00:06:00Z", + "end_time": "2026-10-05T00:36:00Z", + "created_at": "2026-10-05T00:36:00Z", + "source": "manual", + "kind": "reading", + "device_id": "one", + "shelf_ids": [], + "pages": 0, + "coverage": [] + } + ], + "now": "2026-10-05T00:36:00Z", + "expected": { + "seconds": 30, + "pages": 0, + "finished_books": 0, + "days": 0, + "current_value": 0 + } } ] diff --git a/app/test/goals/goal_progress_test.dart b/app/test/goals/goal_progress_test.dart index 25714ae7..94bceb9e 100644 --- a/app/test/goals/goal_progress_test.dart +++ b/app/test/goals/goal_progress_test.dart @@ -22,6 +22,85 @@ void main() { expect(totals.value, expected['current_value']); }); } + test('eligible activity durations preserve originals and merge grouped checkpoints', () { + final fixture = fixtures.last as Map; + final goal = ReadingGoal.fromJson(fixture['goal'] as Map); + final ledger = (fixture['activities'] as List) + .map((a) => ReadingActivity.fromJson(a as Map)) + .toList(); + final progress = projectGoal(goal, ledger, DateTime.parse(fixture['now'] as String)); + expect(progress.hasCreationCutoff, isTrue); + expect(progress.hasOverlappingTime, isTrue); + expect(progress.fraction, closeTo(1 / 60, .00001)); + for (final activity in progress.activities) { + expect(progress.eligibleSecondsFor(activity), 30); + expect(activity.seconds, 1800); + expect(activity.toJson(), ledger.firstWhere((entry) => entry.id == activity.id).toJson()); + } + final reader = ledger + .map( + (entry) => ReadingActivity( + id: entry.id, + bookId: entry.bookId, + bookTitle: entry.bookTitle, + startTime: entry.startTime, + endTime: entry.endTime, + createdAt: entry.createdAt, + source: 'reader', + sessionId: 'session', + ), + ) + .toList(); + final readerProgress = projectGoal(goal, reader, DateTime.parse(fixture['now'] as String)); + final grouped = groupReadingActivities(readerProgress.activities).single; + expect(grouped.seconds, 1800); + expect(readerProgress.eligibleSecondsFor(grouped), 30); + final olderGoal = goal.copyWith(createdAt: DateTime.utc(2026, 10, 5)); + final fullProgress = projectGoal(olderGoal, ledger, DateTime.parse(fixture['now'] as String)); + expect(fullProgress.seconds, 1800); + expect(fullProgress.hasCreationCutoff, isFalse); + expect(fullProgress.hasOverlappingTime, isTrue); + }); + test('separate manual sessions add normally and partial pauses stay excluded', () { + final start = DateTime.utc(2026, 10, 8, 10); + final goal = ReadingGoal( + id: 'goal', + type: GoalType.minutes, + targetValue: 90, + period: GoalPeriod.daily, + startDate: start, + endDate: start.add(const Duration(days: 1)), + createdAt: start, + ); + final sessions = [ + for (var i = 0; i < 3; i++) + ReadingActivity( + id: 'session-$i', + bookId: 'book', + bookTitle: 'Book', + startTime: start.add(Duration(minutes: 30 * i)), + endTime: start.add(Duration(minutes: 30 * (i + 1))), + createdAt: start, + ), + ]; + final progress = projectGoal(goal, sessions, start.add(const Duration(hours: 2))); + expect(progress.seconds, 5400); + expect(progress.reached, isTrue); + expect(progress.hasOverlappingTime, isFalse); + expect(progress.hasCreationCutoff, isFalse); + final paused = goal.copyWith( + rules: [ + GoalRule(at: start, target: 90), + GoalRule(at: start.add(const Duration(minutes: 10)), target: 90, active: false), + GoalRule(at: start.add(const Duration(minutes: 20)), target: 90), + ], + ); + final partial = projectGoal(paused, sessions, start.add(const Duration(hours: 2))); + expect(partial.seconds, 4800); + expect(partial.eligibleSecondsFor(sessions.first), 1200); + expect(partial.eligibleSecondsFor(sessions.last), 1800); + expect(partial.hasOverlappingTime, isFalse); + }); test('Monday weeks and both DST boundaries use calendar days', () { final spring = GoalCalendar.calendarPeriod(GoalPeriod.daily, DateTime.utc(2026, 3, 29, 12), 'Europe/Vilnius'); final autumn = GoalCalendar.calendarPeriod(GoalPeriod.daily, DateTime.utc(2026, 10, 25, 12), 'Europe/Vilnius'); diff --git a/app/test/goals/goals_provider_test.dart b/app/test/goals/goals_provider_test.dart index 6b277734..9b80129c 100644 --- a/app/test/goals/goals_provider_test.dart +++ b/app/test/goals/goals_provider_test.dart @@ -76,6 +76,73 @@ void main() { expect(provider.current.single.pages, 5); expect(provider.progress(retained).seconds, 600); }); + test('selected books are canonical, bounded, durable and exclude other completions', () async { + var now = DateTime.utc(2026, 10, 5, 12); + final books = [ + for (final id in ['a', 'b', 'c']) Book(id: id, title: id, author: '', addedAt: now), + ]; + final store = DataStore()..loadData(books: books); + final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await expectLater( + provider.createGoal( + type: GoalType.books, + target: 12, + period: GoalPeriod.yearly, + scope: GoalScope.book, + scopeId: 'a', + timezone: 'UTC', + ), + throwsArgumentError, + ); + await expectLater( + provider.createGoal( + type: GoalType.books, + target: 3, + period: GoalPeriod.yearly, + scope: GoalScope.book, + bookIds: ['a', 'b', 'b'], + timezone: 'UTC', + ), + throwsArgumentError, + ); + await expectLater( + provider.createGoal( + type: GoalType.minutes, + target: 30, + period: GoalPeriod.daily, + scope: GoalScope.book, + bookIds: List.generate(1001, (i) => 'book-$i'), + timezone: 'UTC', + ), + throwsArgumentError, + ); + await provider.createGoal( + type: GoalType.books, + target: 2, + period: GoalPeriod.yearly, + scope: GoalScope.book, + bookIds: ['b', 'a', 'a'], + timezone: 'UTC', + ); + final goal = store.goalDefinitions.single; + expect(goal.scopeId, 'a'); + expect(ReadingGoal.fromJson(goal.toJson()).selectedBookIds, ['a', 'b']); + await expectLater(provider.updateGoal(goalId: goal.id, target: 3), throwsArgumentError); + now = now.add(const Duration(minutes: 1)); + for (final book in books) { + await provider.logReading(book: store.getBook(book.id)!, end: now, finished: true); + } + expect(provider.current.single.finishedBooks, 2); + final completion = store.effectiveReadingActivities.firstWhere((a) => a.kind == 'completion' && a.bookId == 'b'); + await provider.reverseActivity(completion); + expect(provider.current.single.finishedBooks, 1); + final single = goal.copyWith(bookIds: [], scopeId: 'b'); + expect(single.toJson().containsKey('book_ids'), isFalse); + expect(ReadingGoal.fromJson(single.toJson()).selectedBookIds, ['b']); + }); + test('recurrence, manual correction, deletion and completion undo retain durable history', () async { var now = DateTime.utc(2026, 10, 5, 12); final book = Book(id: 'book', title: 'Physical book', author: '', addedAt: now, isPhysical: true); diff --git a/app/test/pages/goals_page_test.dart b/app/test/pages/goals_page_test.dart index 2aabb4a7..12e0b0a5 100644 --- a/app/test/pages/goals_page_test.dart +++ b/app/test/pages/goals_page_test.dart @@ -3,12 +3,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/pages/goals_page.dart'; import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/widgets/goals/goal_card.dart'; +import 'package:papyrus/widgets/book_details/book_details_tab_rail.dart'; +import 'package:papyrus/widgets/shared/empty_state.dart'; import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:provider/provider.dart'; import '../helpers/test_helpers.dart'; void main() { + Future toggleCompleted(WidgetTester tester) async { + await tester.tap(find.byTooltip('Goal filters')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CheckedPopupMenuItem)); + await tester.pumpAndSettle(); + } + Future pumpEmptyGoalsPage(WidgetTester tester, Size size) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; @@ -20,15 +34,178 @@ void main() { await tester.pumpAndSettle(); } - for (final size in [const Size(400, 800), const Size(1200, 800)]) { + for (final size in [const Size(400, 800), const Size(1200, 800), const Size(1600, 800)]) { testWidgets('empty state keeps creation and logging actions available at ${size.width.toInt()}px', (tester) async { await pumpEmptyGoalsPage(tester, size); - final titleCenter = tester.getCenter(find.text('No goals yet')); - expect(titleCenter.dy, lessThan(size.height / 2)); - expect(find.text('New goal').hitTestable(), findsOneWidget); - expect(find.text('Log reading').hitTestable(), findsOneWidget); - expect(find.text('30 minutes daily'), findsOneWidget); + final placeholder = find.byKey(const Key('goals-empty-state')); + final viewport = tester.getRect(find.byType(TabBarView)); + expect(tester.getRect(placeholder), viewport); + final iconTop = tester.getRect(find.byIcon(Icons.emoji_events_outlined)).top; + final actionBottom = tester + .getRect(find.descendant(of: placeholder, matching: find.byType(EmptyStateAction))) + .bottom; + expect((iconTop + actionBottom) / 2, closeTo(viewport.center.dy, 1)); + expect(find.text('Goals'), findsNothing); + // Only the tab divider remains; there is no separate header border. + expect(find.byType(Divider), findsOneWidget); + const referenceRail = BookDetailsTabRail( + tabs: [ + Tab(text: 'Details'), + Tab(text: 'Notes'), + ], + ); + expect(tester.getSize(find.byType(TabBar)).height, referenceRail.preferredSize.height); + expect( + tester.getRect(find.byType(TabBar)).top, + libraryPageHorizontalPadding(tester.element(find.byType(TabBar))), + ); + if (size.width < 840) { + expect(find.byTooltip('New goal').hitTestable(), findsOneWidget); + expect(find.byTooltip('Log reading').hitTestable(), findsOneWidget); + expect(find.text('New goal').hitTestable(), findsWidgets); + } else { + expect(find.text('New goal').hitTestable(), findsWidgets); + expect(find.text('Log reading').hitTestable(), findsOneWidget); + expect(tester.getRect(find.widgetWithText(OutlinedButton, 'Log reading')).top, Spacing.lg); + final create = find.widgetWithText(FilledButton, 'New goal').first; + expect(tester.getRect(create).top, Spacing.lg); + final log = find.widgetWithText(OutlinedButton, 'Log reading'); + expect( + tester.widget(create).style!.minimumSize!.resolve({})!.height, + TouchTargets.desktopRecommended, + ); + expect(tester.getSize(log).height, TouchTargets.desktopRecommended); + expect(tester.getSize(create).height, TouchTargets.desktopRecommended); + final divider = tester.getRect(find.byType(Divider)); + // Measure the painted surfaces, rather than treating the touch target as the border. + final createSurface = find.descendant(of: create, matching: find.byType(Material)).first; + final logSurface = find.descendant(of: log, matching: find.byType(Material)).first; + expect(divider.top - tester.getRect(createSurface).bottom, greaterThanOrEqualTo(Spacing.sm)); + expect(divider.top - tester.getRect(logSurface).bottom, greaterThanOrEqualTo(Spacing.sm)); + expect(divider.left, Spacing.lg); + expect(size.width - divider.right, Spacing.lg); + expect(tester.getRect(create).right, size.width - Spacing.lg); + expect(find.widgetWithText(LibraryAddButton, 'New goal'), findsOneWidget); + } + expect(find.byType(ActionChip), findsNothing); + expect(find.byType(AppBar), findsNothing); + expect(find.text('Create a goal to track your reading progress.'), findsOneWidget); + await tester.tap(find.descendant(of: placeholder, matching: find.byType(EmptyStateAction))); + await tester.pumpAndSettle(); + expect(find.text('Books finished'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets('desktop sidebar width and large text keep tab actions compact', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(900, 800); + addTearDown(tester.view.reset); + final store = DataStore()..loadData(readingGoals: const []); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp( + theme: AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(2)), + child: child!, + ), + home: const Align(child: SizedBox(width: 560, child: GoalsPage())), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byTooltip('Log reading').hitTestable(), findsOneWidget); + expect(find.byTooltip('New goal').hitTestable(), findsOneWidget); + expect(find.text('Overview').hitTestable(), findsOneWidget); + expect(find.text('Activity').hitTestable(), findsOneWidget); + await tester.tap(find.byTooltip('New goal')); + await tester.pumpAndSettle(); + expect(find.text('Books finished'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('mobile header actions and FAB open their sheets on both tabs above the safe area', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + tester.view.padding = const FakeViewPadding(bottom: 32); + tester.view.viewPadding = const FakeViewPadding(bottom: 32); + addTearDown(tester.view.reset); + final store = DataStore()..loadData(readingGoals: const []); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: AppTheme.dark, home: const GoalsPage()), + ), + ); + await tester.pumpAndSettle(); + final log = find.byTooltip('Log reading'); + final fab = find.byTooltip('New goal'); + expect(find.text('Goals'), findsNothing); + expect(find.byType(Divider), findsOneWidget); + expect(tester.getRect(log).right, greaterThan(340)); + expect(tester.getCenter(log).dy, closeTo(tester.getCenter(find.text('Overview')).dy, 1)); + expect(tester.getSize(log).width, lessThanOrEqualTo(48)); + expect(tester.getRect(fab).bottom, lessThanOrEqualTo(812)); + for (final tab in ['Overview', 'Activity']) { + await tester.tap(find.text(tab)); + await tester.pumpAndSettle(); + expect(find.byType(FloatingActionButton), findsOneWidget); + await tester.tap(fab); + await tester.pumpAndSettle(); + expect(find.text('Books finished'), findsOneWidget); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + await tester.tap(log); + await tester.pumpAndSettle(); + expect(find.text('Save reading'), findsOneWidget); + await tester.tap(find.byTooltip('Close')); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + for (final theme in [AppTheme.dark, AppTheme.light, AppTheme.eink]) { + testWidgets('goal tabs swipe both ways and retain activity scroll in ${theme.brightness}', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + final store = DataStore()..loadData(readingGoals: const []); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: theme, home: const GoalsPage()), + ), + ); + await tester.pumpAndSettle(); + final pages = find.byType(TabBarView); + final controller = tester.widget(pages).controller!; + expect(controller.index, 0); + await tester.drag(pages, const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(controller.index, 1); + expect(find.text('No goals yet'), findsNothing); + final activityScroll = find + .descendant(of: find.byKey(const PageStorageKey('goals-activity-scroll')), matching: find.byType(Scrollable)) + .first; + await tester.scrollUntilVisible(find.text('Filter dates'), 180, scrollable: activityScroll); + await tester.pumpAndSettle(); + final offset = tester.state(activityScroll).position.pixels; + expect(offset, greaterThan(0)); + await tester.drag(pages, const Offset(300, 0)); + await tester.pumpAndSettle(); + expect(controller.index, 0); + expect(find.text('No goals yet'), findsOneWidget); + await tester.tap(find.text('Activity')); + await tester.pumpAndSettle(); + expect(controller.index, 1); + expect(tester.state(activityScroll).position.pixels, closeTo(offset, 1)); + await tester.tap(find.text('Overview')); + await tester.pumpAndSettle(); + expect(controller.index, 0); expect(tester.takeException(), isNull); }); } @@ -60,8 +237,14 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Activity')); await tester.pumpAndSettle(); - final history = find.byKey(const Key('goal-period-history')); - await tester.scrollUntilVisible(history, 250, scrollable: find.byType(Scrollable).first); + final history = find.byKey(const PageStorageKey('goal-period-history')); + await tester.scrollUntilVisible( + history, + 250, + scrollable: find + .descendant(of: find.byKey(const PageStorageKey('goals-activity-scroll')), matching: find.byType(Scrollable)) + .first, + ); await tester.ensureVisible(history); await tester.pumpAndSettle(); await tester.tap(find.text('Goal history')); @@ -69,6 +252,242 @@ void main() { expect(find.text('Missed'), findsWidgets); expect(tester.takeException(), isNull); }); + testWidgets('archived goal activity restores scroll and expansion independently', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 800); + addTearDown(tester.view.reset); + final yesterday = DateTime.now().toUtc().subtract(const Duration(days: 1)); + final now = DateTime.utc(yesterday.year, yesterday.month, yesterday.day, 12); + final store = DataStore() + ..loadData( + readingGoals: [ + ReadingGoal( + id: 'archived', + title: 'Archived reading goal', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + createdAt: now.subtract(const Duration(hours: 1)), + startDate: now.subtract(const Duration(hours: 1)), + endDate: now.add(const Duration(days: 1)), + isActive: false, + isArchived: true, + ), + ], + ); + await store.commitTracking( + activities: [ + ReadingActivity( + id: 'manual', + bookId: 'alice', + bookTitle: 'Alice', + startTime: now.subtract(const Duration(minutes: 10)), + endTime: now, + createdAt: now, + ), + ], + ); + addTearDown(store.dispose); + final bucket = PageStorageBucket(); + Future openPage(int revision) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp( + theme: AppTheme.dark, + home: PageStorage( + bucket: bucket, + child: GoalsPage(key: ValueKey(revision)), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Activity')); + await tester.pumpAndSettle(); + } + + await openPage(0); + final scrollKey = find.byKey(const PageStorageKey('goals-activity-scroll')); + // Mimic the saved zero offset from the web failure. Expansion rows must + // never read a numeric scroll offset as their boolean state. + bucket.writeState(tester.element(scrollKey), 0.0); + await openPage(1); + expect(tester.takeException(), isNull); + + final scrollable = find.descendant(of: scrollKey, matching: find.byType(Scrollable)).first; + final bookRow = find.widgetWithText(ExpansionTile, 'Alice'); + await tester.scrollUntilVisible(bookRow, 200, scrollable: scrollable); + await tester.tap(find.text('Alice')); + await tester.pumpAndSettle(); + final history = find.widgetWithText(ExpansionTile, 'Goal history'); + await tester.scrollUntilVisible(history, 200, scrollable: scrollable); + await tester.ensureVisible(history); + await tester.tap(find.text('Goal history')); + await tester.pumpAndSettle(); + expect(find.text('Archived goals'), findsOneWidget); + final offset = tester.state(scrollable).position.pixels; + expect(offset, greaterThan(0)); + + await openPage(2); + expect(tester.takeException(), isNull); + expect(tester.state(scrollable).position.pixels, closeTo(offset, 1)); + expect(find.text('Archived goals'), findsOneWidget); + await tester.scrollUntilVisible(bookRow, -200, scrollable: scrollable); + expect(find.textContaining('10 min'), findsWidgets); + final tile = tester.widget(bookRow); + final expandedChild = find.byWidget(tile.children.first); + expect(expandedChild.hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('overview ranks real progress across schedules and updates after undo', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 2400); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final created = now.subtract(const Duration(hours: 1)); + final book = Book(id: 'alice', title: 'Alice', author: 'Lewis Carroll', addedAt: created); + ReadingGoal goal(String id, GoalType type, int target, GoalPeriod period, {bool active = true}) => ReadingGoal( + id: id, + title: id, + type: type, + targetValue: target, + period: period, + startDate: created, + endDate: now.add(const Duration(days: 365)), + createdAt: created, + timezone: 'UTC', + isActive: active, + minimumMinutes: 20, + scope: GoalScope.book, + scopeId: book.id, + ); + final store = DataStore() + ..loadData( + books: [book], + readingGoals: [ + goal('Books halfway', GoalType.books, 2, GoalPeriod.yearly), + goal('Not started yet', GoalType.days, 5, GoalPeriod.weekly), + goal('Nearly finished', GoalType.minutes, 20, GoalPeriod.daily), + goal('Finished target', GoalType.books, 1, GoalPeriod.monthly), + goal('On hold', GoalType.pages, 100, GoalPeriod.weekly, active: false), + goal('Just begun', GoalType.minutes, 100, GoalPeriod.weekly), + ], + ); + addTearDown(store.dispose); + final completion = ReadingActivity( + id: 'finished', + bookId: book.id, + bookTitle: book.title, + kind: 'completion', + startTime: now, + endTime: now, + createdAt: now, + ); + await store.commitTracking( + activities: [ + completion, + ReadingActivity( + id: 'reading', + bookId: book.id, + bookTitle: book.title, + startTime: now.subtract(const Duration(minutes: 15)), + endTime: now, + createdAt: now, + ), + ], + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: AppTheme.dark, home: const GoalsPage()), + ), + ); + await tester.pumpAndSettle(); + List cardIds() => tester.widgetList(find.byType(GoalCard)).map((card) => card.goal.id).toList(); + // Reading days require 20 minutes so this period has not qualified yet. + expect(cardIds(), [ + 'Nearly finished', + 'Books halfway', + 'Just begun', + 'Not started yet', + 'Finished target', + 'On hold', + ]); + for (final label in ['In progress', 'Not started', 'Completed', 'Paused']) { + expect(find.text(label), findsWidgets); + } + expect(find.text('Longer-term goals'), findsNothing); + expect(find.text('Today'), findsNothing); + expect(find.textContaining('Finish confirmation required'), findsNothing); + await toggleCompleted(tester); + expect(cardIds(), isNot(contains('Finished target'))); + expect(cardIds(), contains('On hold')); + await tester.tap(find.text('Activity')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Overview')); + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsNothing); + expect(cardIds(), isNot(contains('Finished target'))); + await toggleCompleted(tester); + expect(cardIds(), contains('Finished target')); + await store.commitTracking(activities: [store.reversalFor(completion)]); + await tester.pumpAndSettle(); + expect(find.text('Achieved'), findsNothing); + expect(find.text('Completed'), findsNothing); + expect(cardIds().take(2), ['Nearly finished', 'Just begun']); + expect(store.readingActivities.any((entry) => entry.id == completion.id), isTrue); + expect(tester.takeException(), isNull); + }); + testWidgets('hiding the only completed goal keeps the filter and creation action available', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final goal = ReadingGoal( + id: 'done', + type: GoalType.books, + targetValue: 1, + period: GoalPeriod.daily, + startDate: now.subtract(const Duration(hours: 1)), + endDate: now.add(const Duration(days: 1)), + ); + final store = DataStore()..loadData(readingGoals: [goal]); + addTearDown(store.dispose); + await store.commitTracking( + activities: [ + ReadingActivity( + id: 'finished', + bookId: 'book', + bookTitle: 'Book', + kind: 'completion', + startTime: now, + endTime: now, + createdAt: now, + ), + ], + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: AppTheme.dark, home: const GoalsPage()), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Completed'), findsOneWidget); + expect(find.byType(Switch), findsNothing); + expect(tester.getCenter(find.byTooltip('Goal filters')).dy, closeTo(tester.getCenter(find.text('Overview')).dy, 1)); + await toggleCompleted(tester); + expect(find.byType(GoalCard), findsNothing); + expect(find.text('No goals yet'), findsNothing); + expect(find.text('All goals completed'), findsOneWidget); + expect(find.byTooltip('Goal filters').hitTestable(), findsOneWidget); + expect(find.byTooltip('New goal').hitTestable(), findsOneWidget); + await toggleCompleted(tester); + expect(find.byType(GoalCard), findsOneWidget); + expect(find.text('All goals completed'), findsNothing); + expect(tester.takeException(), isNull); + }); for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { for (final size in [const Size(320, 720), const Size(768, 1024), const Size(1440, 1000)]) { testWidgets('active Goals supports large text at $size in ${theme.brightness}', (tester) async { @@ -114,13 +533,27 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.text('New goal').hitTestable(), findsOneWidget); - expect(find.text('Log reading').hitTestable(), findsOneWidget); - if (size.width > 1000) expect(tester.getSize(find.text('Goals')).width, greaterThan(100)); + if (size.width < 840) { + expect(find.byTooltip('New goal').hitTestable(), findsOneWidget); + expect(find.byTooltip('Log reading').hitTestable(), findsOneWidget); + } else { + expect(find.text('New goal').hitTestable(), findsOneWidget); + expect(find.text('Log reading').hitTestable(), findsOneWidget); + } + expect(find.text('Goals'), findsNothing); expect(find.text('Activity').hitTestable(), findsOneWidget); await tester.tap(find.text('Activity')); await tester.pumpAndSettle(); - await tester.scrollUntilVisible(find.text('Filter dates'), 200, scrollable: find.byType(Scrollable).first); + await tester.scrollUntilVisible( + find.text('Filter dates'), + 200, + scrollable: find + .descendant( + of: find.byKey(const PageStorageKey('goals-activity-scroll')), + matching: find.byType(Scrollable), + ) + .first, + ); await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Filter dates')); await tester.pumpAndSettle(); expect(find.text('Filter dates').hitTestable(), findsOneWidget); diff --git a/app/test/powersync/tracking_persistence_test.dart b/app/test/powersync/tracking_persistence_test.dart index 35b96908..7bdfd2f6 100644 --- a/app/test/powersync/tracking_persistence_test.dart +++ b/app/test/powersync/tracking_persistence_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/reading_activity.dart'; import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/goal_period_record.dart'; import 'package:papyrus/powersync/library_database.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/powersync/powersync_service.dart'; @@ -114,6 +115,59 @@ void main() { expect((await library.snapshot()).activities.single.id, activity.id); await db.close(); }); + test('v1 keeps book sets local across restart while ordinary tracking uploads', () async { + final path = '${directory.path}/book-sets.db'; + final db = PowerSyncDatabase(path: path, schema: papyrusAccountSchema); + await db.initialize(); + final library = LibraryDatabase(db, () async {}); + await library.enableTracking(schemaVersion: 1); + final selected = goal.copyWith(id: 'selected', scope: GoalScope.book, scopeId: 'a', bookIds: ['a', 'b']); + final period = GoalPeriodRecord( + id: 'period', + goalId: selected.id, + definition: selected.copyWith(isRecurring: false), + ); + await library.commitTracking(goals: [selected, goal], activities: [activity], periods: [period]); + expect((await db.getAll('SELECT * FROM tracking_staging')).length, 2); + expect((await db.getAll('SELECT * FROM ps_crud')).length, 2); + await db.close(); + final reopened = PowerSyncDatabase(path: path, schema: papyrusAccountSchema); + await reopened.initialize(); + final supported = LibraryDatabase(reopened, () async {}); + await supported.enableTracking(schemaVersion: 1); + expect((await supported.snapshot()).goals.firstWhere((g) => g.id == 'selected').selectedBookIds, ['a', 'b']); + expect((await reopened.getAll('SELECT * FROM tracking_staging')).length, 2); + expect((await supported.snapshot()).goalPeriods.single.definition.selectedBookIds, ['a', 'b']); + await supported.enableTracking(); + expect(await reopened.getAll('SELECT * FROM tracking_staging'), isEmpty); + expect((await reopened.getAll('SELECT * FROM ps_crud')).length, 4); + await supported.enableTracking(); + expect((await reopened.getAll('SELECT * FROM ps_crud')).length, 4); + await reopened.close(); + }); + test('queued v2 goals defer on a v1 server without blocking library or activity uploads', () async { + final db = PowerSyncDatabase(path: '${directory.path}/book-set-downgrade.db', schema: papyrusAccountSchema); + await db.initialize(); + final library = LibraryDatabase(db, () async {}); + await library.enableTracking(); + final selected = goal.copyWith(scope: GoalScope.book, scopeId: 'a', bookIds: ['a', 'b']); + await library.commitTracking(goals: [selected], activities: [activity]); + await library.upsert('books', PowerSyncBookMapper.toRow(book)); + final auth = CapturingUploadRepository(); + final connector = PapyrusPowerSyncConnector( + authRepository: auth, + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://example.invalid')), + trackingSchemaVersion: () => 1, + ); + await connector.uploadData(db); + expect(auth.batches.expand((batch) => batch).map((entry) => entry['type']), ['reading_activities', 'books']); + expect(await db.getAll('SELECT * FROM ps_crud'), isEmpty); + expect((await db.getAll('SELECT * FROM tracking_staging')).length, 1); + await library.enableTracking(); + expect(await db.getAll('SELECT * FROM tracking_staging'), isEmpty); + expect((await db.getAll('SELECT * FROM ps_crud')).length, 1); + await db.close(); + }); test('captured repository is invalidated on account switch and guest data is isolated', () async { final db = service(); await db.activateGuest(); diff --git a/app/test/widgets/goals/goal_details_sheets_test.dart b/app/test/widgets/goals/goal_details_sheets_test.dart index 7128e3a6..5739ac08 100644 --- a/app/test/widgets/goals/goal_details_sheets_test.dart +++ b/app/test/widgets/goals/goal_details_sheets_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/goals/goal_calendar.dart'; import 'package:papyrus/models/reading_goal.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/widgets/goals/goal_card.dart'; import 'package:papyrus/providers/goals_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; @@ -10,6 +13,75 @@ import 'package:papyrus/widgets/goals/add_goal_sheet.dart'; import 'package:papyrus/widgets/shared/app_bottom_sheet.dart'; void main() { + for (final width in [390.0, 1200.0]) { + testWidgets('partial manual time agrees with goal summary and retains original entries at $width', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 1200); + addTearDown(tester.view.reset); + final now = DateTime.utc(2026, 10, 8, 0, 36); + final book = Book(id: 'book', title: 'SQL Performance Explained', author: 'Markus Winand', addedAt: now); + final goal = ReadingGoal( + id: 'daily', + type: GoalType.minutes, + targetValue: 30, + period: GoalPeriod.daily, + startDate: DateTime.utc(2026, 10, 8), + endDate: DateTime.utc(2026, 10, 9), + createdAt: now.subtract(const Duration(seconds: 30)), + scope: GoalScope.book, + scopeId: book.id, + ); + final store = DataStore()..loadData(books: [book], readingGoals: [goal]); + await store.commitTracking( + activities: [ + for (var i = 0; i < 3; i++) + ReadingActivity( + id: 'manual-$i', + bookId: book.id, + bookTitle: book.title, + startTime: now.subtract(const Duration(minutes: 30)), + endTime: now, + createdAt: now, + ), + ], + ); + final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + expect(goalCount(provider.progress(goal)), '30 sec / 30m'); + expect(goalRemaining(provider.progress(goal)), '29m 30s to go'); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GoalDetailsSheet.show(context, goal: goal, provider: provider), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('30 sec / 30m'), findsOneWidget); + expect(find.text('0 / 30 minutes'), findsNothing); + expect(find.text('29m 30s to go'), findsOneWidget); + expect(find.text('30 sec for this goal'), findsNWidgets(3)); + expect(find.text('30 min · Manual'), findsNWidgets(3)); + expect(find.textContaining('Overlapping time is counted once.'), findsOneWidget); + final readingBar = tester.widget(find.byType(LinearProgressIndicator)); + expect(readingBar.value, closeTo(1 / 60, .00001)); + await tester.ensureVisible(find.text('30 min · Manual').first); + await tester.tap(find.text('30 min · Manual').first); + await tester.pumpAndSettle(); + expect(find.text('30m'), findsOneWidget); + expect(find.text('Correct entry'), findsOneWidget); + expect(store.readingActivities.every((entry) => entry.seconds == 1800), isTrue); + expect(tester.takeException(), isNull); + }); + } testWidgets('editing a metric previews replacement and retains the original goal', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1000, 1400); @@ -48,7 +120,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Pages read').last); await tester.pumpAndSettle(); - expect(find.textContaining('starts a replacement goal'), findsOneWidget); + expect(find.textContaining('creates a replacement goal'), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Replace goal')); await tester.pumpAndSettle(); expect(store.getReadingGoal('original')!.isArchived, isTrue); @@ -99,6 +171,80 @@ void main() { expect(store.getReadingGoal('deadline')!.isArchived, isFalse); expect(tester.takeException(), isNull); }); + for (final size in [const Size(390, 844), const Size(1200, 1000)]) { + testWidgets('details show a plain scoped summary with activity and live actions at $size', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final book = Book(id: 'book', title: 'Alice’s Adventures in Wonderland', author: 'Lewis Carroll', addedAt: now); + final goal = ReadingGoal( + id: 'goal', + type: GoalType.books, + targetValue: 1, + period: GoalPeriod.monthly, + scope: GoalScope.book, + scopeId: book.id, + startDate: now.subtract(const Duration(hours: 1)), + endDate: now.add(const Duration(days: 30)), + ); + final store = DataStore()..loadData(books: [book], readingGoals: [goal]); + await store.commitTracking( + activities: [ + ReadingActivity( + id: 'finished', + bookId: book.id, + bookTitle: book.title, + kind: 'completion', + startTime: now.subtract(const Duration(minutes: 5)), + endTime: now.subtract(const Duration(minutes: 5)), + createdAt: now, + ), + ], + ); + final provider = GoalsProvider(watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(size.width < 500 ? 2 : 1)), + child: child!, + ), + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => GoalDetailsSheet.show(context, goal: goal, provider: provider), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.byType(GoalCard), findsNothing); + expect(find.byType(Card), findsNothing); + expect(find.text('Counted activity'), findsNothing); + expect(find.text('Calendar settings'), findsNothing); + expect(find.text('Reading activity'), findsOneWidget); + expect(find.text('Read 1 book this month'), findsOneWidget); + expect(find.text('1 / 1 book'), findsOneWidget); + expect(find.text('Target reached'), findsOneWidget); + expect(find.text('Whole library'), findsNothing); + expect(find.text(book.title), findsWidgets); + await tester.ensureVisible(find.text('Pause goal')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pause goal')); + await tester.pumpAndSettle(); + expect(store.getReadingGoal(goal.id)!.isActive, isFalse); + expect(find.text('Paused'), findsOneWidget); + expect(find.text('Resume goal'), findsOneWidget); + expect(store.readingActivities, hasLength(1)); + expect(tester.takeException(), isNull); + }); + } for (final width in [320.0, 390.0, 1200.0]) { for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { testWidgets('goal sheet footer is above system navigation at $width/${theme.brightness}', (tester) async { @@ -137,12 +283,17 @@ void main() { await tester.tap(find.text('Open')); await tester.pumpAndSettle(); final done = find.widgetWithText(FilledButton, 'Done'); - final delete = find.widgetWithText(OutlinedButton, 'Delete goal'); + final delete = find.widgetWithText(OutlinedButton, 'Delete'); expect(done.hitTestable(), findsOneWidget); expect(delete.hitTestable(), findsOneWidget); expect(find.byTooltip('Close').hitTestable(), findsOneWidget); expect(tester.getRect(done).bottom, lessThanOrEqualTo(652)); expect(tester.getRect(delete).top, tester.getRect(done).top); + expect(find.widgetWithText(OutlinedButton, 'Delete goal'), findsNothing); + if (width < 600) { + expect(tester.getSize(delete).width, closeTo(tester.getSize(done).width, 1)); + expect(tester.getSize(find.text('Delete')).height, lessThan(tester.getSize(delete).height)); + } expect(tester.takeException(), isNull); await tester.tap(done); await tester.pumpAndSettle(); diff --git a/app/test/widgets/goals/goals_ui_refinement_test.dart b/app/test/widgets/goals/goals_ui_refinement_test.dart index 83be5f86..bac4fc81 100644 --- a/app/test/widgets/goals/goals_ui_refinement_test.dart +++ b/app/test/widgets/goals/goals_ui_refinement_test.dart @@ -13,6 +13,8 @@ import 'package:papyrus/widgets/goals/goal_details_sheet.dart'; import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; import 'package:papyrus/widgets/goals/reading_activity_heatmap.dart'; import 'package:papyrus/widgets/goals/reading_activity_timeline.dart'; +import 'package:papyrus/widgets/shared/searchable_book_field.dart'; +import 'package:papyrus/widgets/shared/searchable_books_field.dart'; void main() { final now = DateTime.now().toUtc(); @@ -70,8 +72,7 @@ void main() { return value; } - testWidgets('desktop rows align mixed goal cards and their reading actions', (tester) async { - final semantics = tester.ensureSemantics(); + testWidgets('desktop rows align goal cards without reader launch controls', (tester) async { await size(tester); final store = DataStore()..loadData(books: [book], readingGoals: [short, days]); addTearDown(store.dispose); @@ -85,10 +86,7 @@ void main() { final left = tester.getRect(find.byKey(const ValueKey('goal-card-short'))); final right = tester.getRect(find.byKey(const ValueKey('goal-card-days'))); expect(left.height, right.height); - expect(find.bySemanticsLabel('Continue reading'), findsNWidgets(2)); - semantics.dispose(); - final actions = find.widgetWithText(TextButton, 'Continue reading'); - expect(tester.getRect(actions.at(0)).bottom, tester.getRect(actions.at(1)).bottom); + expect(find.text('Continue reading'), findsNothing); final log = find.widgetWithText(OutlinedButton, 'Log reading'); expect(tester.getSize(log).width, lessThan(300)); final theme = Theme.of(tester.element(log)); @@ -126,30 +124,97 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('timezone is advanced, searchable, selectable and saved as an IANA identifier', (tester) async { + testWidgets('goal form uses device timezone without an advanced settings section', (tester) async { final store = DataStore()..loadData(); final goals = provider(store); await showSheet( tester, - (context) => AddGoalSheet.show(context, provider: goals, preset: 0, initialTimezone: 'UTC'), + (context) => AddGoalSheet.show(context, provider: goals, preset: 0, initialTimezone: 'Europe/Vilnius'), ); - expect(find.byKey(const Key('goal-timezone-button')).hitTestable(), findsNothing); - expect(find.byWidgetPredicate((w) => w is TextField && w.decoration?.labelText == 'Timezone'), findsNothing); - await tester.tap(find.text('Advanced settings')); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('goal-timezone-button'))); + expect(find.text('Advanced settings'), findsNothing); + expect(find.byKey(const Key('goal-timezone-button')), findsNothing); + expect(find.text('Repeat each period'), findsOneWidget); + expect( + tester.getTopLeft(find.widgetWithText(TextFormField, 'Name (optional)')).dy, + lessThan(tester.getTopLeft(find.widgetWithText(DropdownButtonFormField, 'Measure')).dy), + ); + await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); await tester.pumpAndSettle(); - await tester.enterText( - find.byWidgetPredicate((w) => w is TextField && w.decoration?.labelText == 'Search city or timezone'), - 'Vilnius', + expect(store.goalDefinitions.single.timezone, 'Europe/Vilnius'); + expect(tester.takeException(), isNull); + }); + + testWidgets('deletion confirms directly from the card menu and preserves activity', (tester) async { + await size(tester); + final entry = ReadingActivity( + id: 'entry', + bookId: book.id, + bookTitle: book.title, + startTime: now, + endTime: now, + createdAt: now, + pages: 5, + ); + final store = DataStore()..loadData(books: [book], readingGoals: [short]); + await store.commitTracking(activities: [entry]); + addTearDown(store.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: store, + child: MaterialApp(theme: AppTheme.dark, home: const GoalsPage()), + ), ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ListTile, 'Vilnius')); + await tester.tap(find.byTooltip('Goal actions')); await tester.pumpAndSettle(); - expect(find.text('Timezone: Vilnius'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); + await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); - expect(store.goalDefinitions.single.timezone, 'Europe/Vilnius'); + expect(find.byType(GoalDetailsSheet), findsNothing); + expect(find.text('Delete goal?'), findsOneWidget); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions, hasLength(1)); + await tester.tap(find.byTooltip('Goal actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions, isEmpty); + expect(store.readingActivities.single.id, entry.id); + expect(tester.takeException(), isNull); + }); + + testWidgets('manual logging searches books by title author and ISBN before saving', (tester) async { + final other = Book(id: 'other', title: 'Dune', author: 'Frank Herbert', isbn: '9780441172719', addedAt: now); + final store = DataStore()..loadData(books: [book, other]); + final goals = provider(store); + await showSheet(tester, (context) => LogReadingSheet.show(context, provider: goals)); + tester.view.physicalSize = const Size(390, 844); + await tester.pumpAndSettle(); + expect(find.text('Session ends at'), findsNothing); + expect(find.textContaining('Manual entries count'), findsNothing); + await tester.tap(find.byType(SearchableBookField)); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 280); + await tester.pumpAndSettle(); + final search = find.byWidgetPredicate((w) => w is TextField && w.decoration?.hintText == 'Search books'); + for (final query in ['dune', 'herbert', '9780441172719']) { + await tester.enterText(search, query); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, 'Dune'), findsOneWidget); + expect(find.widgetWithText(ListTile, book.title), findsNothing); + } + expect(tester.getRect(find.widgetWithText(ListTile, 'Dune')).bottom, lessThanOrEqualTo(564)); + await tester.tap(find.widgetWithText(ListTile, 'Dune')); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(); + await tester.pumpAndSettle(); + await tester.enterText(find.widgetWithText(TextFormField, 'Minutes read'), '15'); + await tester.tap(find.widgetWithText(FilledButton, 'Save reading')); + await tester.pumpAndSettle(); + expect(store.effectiveReadingActivities.single.bookId, other.id); + expect(store.effectiveReadingActivities.single.seconds, 900); expect(tester.takeException(), isNull); }); @@ -204,6 +269,104 @@ void main() { }); } + for (final metric in ['Books finished', 'Pages read', 'Reading time', 'Reading days']) { + testWidgets('$metric starts with Daily selected', (tester) async { + final store = DataStore()..loadData(); + final goals = provider(store); + await showSheet(tester, (context) => AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC')); + await tester.tap(find.widgetWithText(ListTile, metric)); + await tester.pumpAndSettle(); + expect(tester.widget(find.widgetWithText(ChoiceChip, 'Daily')).selected, isTrue); + await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.single.period, GoalPeriod.daily); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('book selection survives searching and bounds the editable completion target', (tester) async { + final other = Book(id: 'other', title: 'Dune', author: 'Frank Herbert', addedAt: now); + final store = DataStore()..loadData(books: [book, other]); + final goals = provider(store); + await showSheet(tester, (context) => AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC')); + await tester.tap(find.widgetWithText(ListTile, 'Books finished')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(DropdownButtonFormField, 'Include')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Selected books').last); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.byType(SearchableBooksField)); + await tester.tap(find.widgetWithText(OutlinedButton, 'Choose books')); + await tester.pumpAndSettle(); + tester.view.physicalSize = const Size(390, 844); + tester.view.viewInsets = const FakeViewPadding(bottom: 280); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(CheckboxListTile, book.title)); + final search = find.byWidgetPredicate((w) => w is TextField && w.decoration?.hintText == 'Search books'); + await tester.enterText(search, 'Herbert'); + await tester.pumpAndSettle(); + expect(find.widgetWithText(CheckboxListTile, book.title), findsNothing); + await tester.tap(find.widgetWithText(CheckboxListTile, 'Dune')); + await tester.pumpAndSettle(); + final select = find.widgetWithText(FilledButton, 'Select (2)'); + expect(select.hitTestable(), findsOneWidget); + expect(tester.getRect(select).bottom, lessThanOrEqualTo(564)); + await tester.tap(select); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(); + tester.view.physicalSize = const Size(1200, 1100); + await tester.pumpAndSettle(); + final target = find.byKey(const Key('goal-target-input')); + await tester.ensureVisible(target); + expect(tester.widget(target).controller!.text, '2'); + await tester.enterText(target, '12'); + await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); + await tester.pumpAndSettle(); + expect(find.text('Choose a target of 1–2.'), findsOneWidget); + expect(store.goalDefinitions, isEmpty); + await tester.enterText(target, '1'); + await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.single.selectedBookIds, ['book', 'other']); + expect(store.goalDefinitions.single.targetValue, 1); + expect(store.goalDefinitions.single.period, GoalPeriod.daily); + expect(tester.takeException(), isNull); + }); + + testWidgets('editing keeps the schedule and replaces a goal when a selected book is removed', (tester) async { + final other = Book(id: 'other', title: 'Dune', author: 'Frank Herbert', addedAt: now); + final goal = short.copyWith( + type: GoalType.books, + targetValue: 2, + period: GoalPeriod.yearly, + bookIds: ['book', 'other'], + ); + final store = DataStore()..loadData(books: [book, other], readingGoals: [goal]); + final goals = provider(store); + await showSheet(tester, (context) => AddGoalSheet.show(context, provider: goals, editing: goal)); + expect(tester.widget(find.widgetWithText(ChoiceChip, 'Yearly')).selected, isTrue); + await tester.enterText(find.widgetWithText(TextFormField, 'Name (optional)'), 'Chosen books'); + await tester.tap(find.widgetWithText(FilledButton, 'Save')); + await tester.pumpAndSettle(); + expect(store.goalDefinitions.single.id, goal.id); + expect(store.goalDefinitions.single.selectedBookIds, ['book', 'other']); + final updated = store.goalDefinitions.single; + await showSheet(tester, (context) => AddGoalSheet.show(context, provider: goals, editing: updated)); + final chip = find.widgetWithText(Chip, 'Dune'); + await tester.ensureVisible(chip); + await tester.pumpAndSettle(); + await tester.tap(find.descendant(of: chip, matching: find.byIcon(Icons.cancel))); + await tester.pumpAndSettle(); + expect(tester.widget(find.byKey(const Key('goal-target-input'))).controller!.text, '1'); + await tester.tap(find.widgetWithText(FilledButton, 'Replace goal')); + await tester.pumpAndSettle(); + expect(store.getReadingGoal(goal.id)!.isArchived, isTrue); + expect(store.getReadingGoal(goal.id)!.selectedBookIds, ['book', 'other']); + expect(goals.current.single.goal.selectedBookIds, ['book']); + expect(goals.current.single.goal.targetValue, 1); + expect(tester.takeException(), isNull); + }); + testWidgets('goal targets and deadline controls stay bounded', (tester) async { final store = DataStore()..loadData(books: [book]); final goals = provider(store); @@ -221,20 +384,20 @@ void main() { final store = DataStore()..loadData(); final goals = provider(store); await showSheet(tester, (context) => AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC')); - expect(find.text('What would you like to track?'), findsOneWidget); + expect(find.text('What would you like to track?'), findsNothing); await tester.tap(find.widgetWithText(ListTile, 'Pages read')); await tester.pumpAndSettle(); final target = find.byKey(const Key('goal-target-input')); await tester.enterText(target, '73'); - await tester.tap(find.byTooltip('Increase target')); - await tester.pump(); - expect(find.text('74'), findsOneWidget); - await tester.tap(find.byTooltip('Decrease target')); + expect(find.byTooltip('Increase target'), findsNothing); await tester.tap(find.widgetWithText(ChoiceChip, 'Monthly')); await tester.pump(); + await tester.tap(find.text('Repeat each period')); + await tester.pump(); await tester.tap(find.widgetWithText(FilledButton, 'Create goal')); await tester.pumpAndSettle(); expect(store.goalDefinitions.single.targetValue, 73); + expect(store.goalDefinitions.single.isRecurring, isFalse); expect(store.goalDefinitions.single.period, GoalPeriod.monthly); expect(store.goalDefinitions.single.type, GoalType.pages); expect(tester.takeException(), isNull); diff --git a/app/test/widgets/goals/reading_activity_details_test.dart b/app/test/widgets/goals/reading_activity_details_test.dart new file mode 100644 index 00000000..06620456 --- /dev/null +++ b/app/test/widgets/goals/reading_activity_details_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/reading_activity.dart'; +import 'package:papyrus/providers/goals_provider.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/book_details/book_cover_image.dart'; +import 'package:papyrus/widgets/goals/log_reading_sheet.dart'; +import 'package:papyrus/widgets/shared/searchable_book_field.dart'; + +void main() { + for (final width in [390.0, 1200.0]) { + testWidgets('activity presents book metadata and a single safe footer at $width', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 1000); + tester.view.padding = const FakeViewPadding(bottom: 48); + tester.view.viewPadding = const FakeViewPadding(bottom: 48); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final book = Book(id: 'book', title: 'Alice’s Adventures in Wonderland', author: 'Lewis Carroll', addedAt: now); + final entry = ReadingActivity( + id: 'entry', + bookId: book.id, + bookTitle: book.title, + kind: 'completion', + startTime: now, + endTime: now, + createdAt: now, + note: 'A lovely ending.', + ); + final store = DataStore()..loadData(books: [book]); + await store.commitTracking(activities: [entry]); + final provider = GoalsProvider(watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(width < 500 ? 2 : 1)), + child: child!, + ), + home: Scaffold( + body: ReadingActivityTile(activity: entry, provider: provider), + ), + ), + ); + await tester.tap(find.text(book.title)); + await tester.pumpAndSettle(); + expect(find.text(book.author), findsOneWidget); + expect(find.byType(CoverImagePreview), findsOneWidget); + expect(find.text('Finished book'), findsOneWidget); + expect(find.text('Note'), findsOneWidget); + expect(find.text(entry.note!), findsOneWidget); + expect(find.text('Cancel'), findsNothing); + final done = find.widgetWithText(FilledButton, 'Done'); + expect(done.hitTestable(), findsOneWidget); + expect(tester.getRect(done).bottom, lessThanOrEqualTo(952)); + await tester.ensureVisible(find.text('Undo this entry')); + await tester.tap(find.text('Undo this entry')); + await tester.pumpAndSettle(); + expect(find.text('Undo reading entry?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Undo entry')); + await tester.pumpAndSettle(); + expect(store.readingActivities.any((a) => a.kind == 'reversal' && a.correctionOf == entry.id), isTrue); + expect(tester.takeException(), isNull); + }); + } + testWidgets('correction uses fixed book context and saves to the original book', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 1000); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final book = Book(id: 'alice', title: 'Alice’s Adventures in Wonderland', author: 'Lewis Carroll', addedAt: now); + final entry = ReadingActivity( + id: 'entry', + bookId: book.id, + bookTitle: book.title, + kind: 'completion', + startTime: now, + endTime: now, + createdAt: now, + ); + final store = DataStore()..loadData(books: [book]); + await store.commitTracking(activities: [entry]); + final provider = GoalsProvider(watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => LogReadingSheet.show(context, provider: provider, book: book, correcting: entry), + child: const Text('Correct'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Correct')); + await tester.pumpAndSettle(); + expect(find.byType(SearchableBookField), findsNothing); + expect(find.byIcon(Icons.search), findsNothing); + expect(find.text(book.title), findsOneWidget); + expect(find.text(book.author), findsOneWidget); + expect(find.byType(CoverImagePreview), findsOneWidget); + final note = find.byWidgetPredicate( + (widget) => widget is TextField && widget.decoration?.labelText == 'Note (optional)', + ); + await tester.enterText(note, 'Updated note'); + await tester.tap(find.widgetWithText(FilledButton, 'Save reading')); + await tester.pumpAndSettle(); + expect(store.readingActivities.where((a) => a.kind == 'reversal' && a.correctionOf == entry.id), hasLength(1)); + final replacement = store.readingActivities.singleWhere((a) => a.note == 'Updated note'); + expect(replacement.bookId, book.id); + expect(replacement.kind, 'completion'); + expect(tester.takeException(), isNull); + }); + testWidgets('removed book retains title and precise reader duration without correction controls', (tester) async { + final now = DateTime.now().toUtc(); + final entry = ReadingActivity( + id: 'entry', + bookId: 'removed', + bookTitle: 'Remembered book', + source: 'reader', + startTime: now.subtract(const Duration(seconds: 34)), + endTime: now, + createdAt: now, + ); + final store = DataStore(); + final provider = GoalsProvider(watchClock: false)..attach(store); + addTearDown(provider.dispose); + addTearDown(store.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: ReadingActivityTile(activity: entry, provider: provider), + ), + ), + ); + await tester.tap(find.text(entry.bookTitle)); + await tester.pumpAndSettle(); + expect(find.text(entry.bookTitle), findsWidgets); + expect(find.text('34 sec'), findsOneWidget); + expect(find.text('This book is no longer in your library.'), findsOneWidget); + expect(find.text('Correct entry'), findsNothing); + expect(find.text('Cancel'), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/docs/goals-tracking.md b/docs/goals-tracking.md index 6c58a85b..db73954d 100644 --- a/docs/goals-tracking.md +++ b/docs/goals-tracking.md @@ -43,16 +43,42 @@ progress, and can be reprojected after corrections arrive. ## Sync and compatibility -`GET /v1/sync/settings` advertises `tracking_schema_version: 1`. New tables are +`GET /v1/sync/settings` advertises `tracking_schema_version: 2`. Version 2 +adds goals scoped to multiple selected books through `book_ids`; single-book and +library goals retain version-1 payloads. A books-finished target cannot exceed +the selected book count. The server validates ownership of every selected book. New tables are `reading_goals`, `reading_activities`, and `goal_periods`, with an owner-scoped JSON payload. Older servers keep tracking in local-only `tracking_staging`. Queued tracking after a server downgrade is retained there while ordinary library uploads -continue. Capability discovery promotes staged records atomically. +continue. Capability discovery promotes supported staged records atomically. Multi-book +goals remain staged against version-1 servers without blocking ordinary tracking +or library synchronization. Deploy the server migration, PostgreSQL publication/grants, and PowerSync streams before releasing this client. Versions are unchanged; merge feature PRs into `development` and use the normal release workflow. +## UI review + +The current Goals page uses swipeable Overview and Activity tabs with shared +collection-page spacing and controls. Overview groups goals by progress and offers +a compact completed-goal filter; phones use a New goal FAB. Creation defaults to +Daily and supports an editable target, recurrence, shelves, and selected books. +Activity groups book/day entries with expandable details and archived history. +Expansion state is stored separately from scroll positions. + +These screenshots show the production widgets in an isolated preview with +synthetic library and activity fixtures; they demonstrate presentation, not live +reader or synchronization validation. + +![Desktop Overview](images/goals-review/overview-desktop.png) + +![Activity and full-width heatmap](images/goals-review/activity-desktop.png) + +![Daily goal creation with editable target](images/goals-review/new-goal.png) + +![Mobile Overview and New goal FAB](images/goals-review/overview-mobile.png) + ## Validation The isolated validation host uses real EPUB/PDF files and records actual reading. diff --git a/docs/images/goals-review/activity-desktop.png b/docs/images/goals-review/activity-desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..0a65e8b7760dfaabfe57659598e153c5ba4ab991 GIT binary patch literal 63957 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 literal 0 HcmV?d00001 diff --git a/docs/images/goals-review/new-goal.png b/docs/images/goals-review/new-goal.png new file mode 100644 index 0000000000000000000000000000000000000000..ec01cf454cc7d79d8400d6c1c7ca054883813a3b GIT binary patch literal 47020 zcmeFZ2Ut_f)-b#kvB1%D5CNsA2c(52C3LVrK$-|i2ql1YNGK9I2zI0xkP?utlt2=S z0Rn^~iqs$=y`%IFf;0i)k9y9%=brbR?|JX{egFUb@AKT*&z`;Z%$nIVYu2>2*6jVh z{g1#e+8SCKz<~n*aDaXR`(p=BYpJPO-Z0eH(9(r`k2rV~pu0mC0l>x86Jw}(^}Ml( z>G>lcf28;pXJzAY>)Z2h96jHSo^QDWK&RN>;QX(GkJ;LJ*w7I^(QjJ}{p0k)!szfR zhaa%xH`w|IeES>h<9W-Ij`Q>zjDZ=d(cw%wEadP9*!mB!%`MC~{C9L5Wfy0!Z{Pan zzLoeVJ6AZA{?wq~+<-dZ1b6_q0B67o@B_{RHvnn6uStJ%0lWZDI_yKo@B|#`kOyE$ zkGV>RU;u`W;Yts&2khuD@Qr@-{OIn%2mq+O+TY)=_-B~*D*zzN0ss^BpJ75u0KoDS z05rDzGwh!2BQJaqUla zScN#*IfedhxBnVoIeIYU&`X8`yud-00}Lz&_G{@CH~<`EIPi^=e|HBN84evjaO5aG z7WoTsfPn#EU}9oC#>mKc<0D16h&2lnv%biv3vV^q!de)`H$4t{l;%py5EuLs37Z|S+8p?~(9`~Ac8 z=l9&{QNOa#gAX43W62I2WI4&o@arK-1FI{H`vbsF40H?@1{UBFu=@@W0&IhiG_OIA zwN^r+2XiDJptR*EQVo1n$r{A;^ie4DqPVeLy*C8sM8u#zW4I!05*~WMEBkfN@;qEyZz}_rU(w`m8jP@UQ5-c*7&k|r z-3Kf#8AO&U&%@PRM9YW`RYPU!Ju77^bsKEawN22U^(uF>+h=E;60Duy4}QFF)Rn

@toTR8ADX0a0@a=RNTnFIk2u6 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 literal 0 HcmV?d00001 diff --git a/docs/images/goals-review/overview-mobile.png b/docs/images/goals-review/overview-mobile.png new file mode 100644 index 0000000000000000000000000000000000000000..b0a1d2dd5aec4bda67b455e146bcee3a1fe8980c GIT binary patch literal 23925 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 literal 0 HcmV?d00001