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..f8dc6e88 --- /dev/null +++ b/app/lib/goals/goal_progress.dart @@ -0,0 +1,206 @@ +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, + this.activityIntervals = const {}, + this.hasOverlappingTime = false, + this.hasCreationCutoff = false, + }); + 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; + + /// 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(), + 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 => goal.selectedBookIds.contains(activity.bookId), + 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 = []; + final activityIntervals = >{}; + var hasCreationCutoff = false; + 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) { + 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; + } + } + 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, + 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/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/main.dart b/app/lib/main.dart index a9e0863f..e5df180d 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,16 @@ 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, + trackingSchemaVersion: () => _powerSyncService.trackingSchemaVersion, 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..36aa1c3d 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,81 @@ 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 List bookIds; + 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.bookIds = const [], + 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; + List get selectedBookIds => scope != GoalScope.book + ? const [] + : bookIds.isNotEmpty + ? bookIds + : [?scopeId]; 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 => 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', + 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 ${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 ${unitLabel(remaining)}'} to go'; + String get recurrenceLabel => isRecurring && !isCustomPeriod ? 'Recurring' : 'One-off'; ReadingGoal copyWith({ String? id, String? title, @@ -172,69 +145,88 @@ class ReadingGoal { GoalPeriod? period, DateTime? startDate, DateTime? endDate, + DateTime? createdAt, + String? timezone, + GoalScope? scope, + String? scopeId, + List? bookIds, + 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, + bookIds: bookIds ?? this.bookIds, + 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, + if (bookIds.length > 1) 'book_ids': bookIds, + '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?, + 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))) + .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..6dd6d22b 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -1,336 +1,660 @@ import 'package:flutter/material.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/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/goals/active_goal_details_sheet.dart'; +import 'package:papyrus/themes/app_motion.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/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/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; - +class _GoalsPageState extends State with TickerProviderStateMixin { + final _provider = GoalsProvider(); + bool _activity = false; + bool _hideCompleted = false; + late TabController _tabs; @override void initState() { super.initState(); - _provider = GoalsProvider(); + _tabs = TabController(length: 2, vsync: this); + _tabs.addListener(_onTabChanged); + } + + void _onTabChanged() { + if (_activity != (_tabs.index == 1)) setState(() => _activity = _tabs.index == 1); } + String? _filterGoal; + DateTimeRange? _filterDates; + int? _activityYear; + String _activityKind = 'all'; @override void didChangeDependencies() { super.didChangeDependencies(); - // Connect to DataStore for persistent storage - final dataStore = context.read(); - _provider.attach(dataStore); + 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(); } @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); - }, + Widget build(BuildContext context) => AnimatedBuilder( + 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 = 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), + ], + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + 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)), + ), + ], ); } - // ============================================================================ - // 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), - ), - body: SafeArea( - child: hasGoals - ? RefreshIndicator( - onRefresh: provider.refresh, - child: ListView( - padding: const EdgeInsets.all(Spacing.md), - 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(), - ), - ), - ], - ], - const SizedBox(height: Spacing.lg), + 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'), + ), ], ), - ) - : _buildEmptyState(context), + ), + 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: 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'), + ], + ); + } + 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'), + ), + ), + ), + ], ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ + List _overview(BuildContext context) { + final current = _provider.current; + // 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); + }); + } - Widget _buildDesktopLayout(BuildContext context, GoalsProvider provider) { - final textTheme = Theme.of(context).textTheme; - final hasGoals = provider.hasActiveGoals || provider.hasCompletedGoals; + final texts = Theme.of(context).textTheme; + return [ + 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), + ], + ]; + } - return Scaffold( - body: SafeArea( - child: hasGoals - ? SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _cards( + BuildContext context, + List goals, { + bool historical = false, + bool currentDetails = false, + }) => LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 760 && MediaQuery.textScalerOf(context).scale(16) <= 24 ? 2 : 1; + 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), + onTap: () => GoalDetailsSheet.show( + context, + goal: progress.goal, + provider: _provider, + historical: historical && !currentDetails ? progress : null, + ), + onMenu: historical ? null : (action) => _goalAction(progress.goal, action), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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: [ - // 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'), - ), - ], - ), - 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)), - ], + Expanded(child: card(goals[i])), + const SizedBox(width: Spacing.md), + Expanded(child: i + 1 < goals.length ? card(goals[i + 1]) : const SizedBox()), ], ), - ) - : _buildEmptyState(context), + ), + ], + ], + ); + }, + ); + String _scopeLabel(ReadingGoal goal) => switch (goal.scope) { + GoalScope.library => 'Whole library', + 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) { + 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 selected = _filterGoal == null + ? null + : _provider.store.getReadingGoal(_filterGoal!) ?? periods.where((p) => p.goal.id == _filterGoal).first.goal; + 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, ); - } - - 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, + final archivedIds = _provider.store.goalDefinitions.where((goal) => goal.isArchived).map((goal) => goal.id).toSet(); + // Archived goals have one summary below; their previous periods remain + // inspectable in goal details rather than appearing as duplicate cards. + final filtered = periods + .where( + (p) => + !archivedIds.contains(p.goal.id) && + (_filterGoal == null || p.goal.id == _filterGoal) && + p.range.start.isBefore(range.end) && + p.range.end.isAfter(range.start), + ) + .toList(); + final archived = _provider.store.goalDefinitions + .where((goal) => goal.isArchived && (_filterGoal == null || _filterGoal == goal.id)) + .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.sm, + runSpacing: Spacing.md, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + SizedBox( + 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 scope'), + items: [ + const DropdownMenuItem(value: '', child: Text('Whole library')), + 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; + _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)}', + ), + ), + if (_filterDates != null) + TextButton(onPressed: () => setState(() => _filterDates = null), child: const Text('Clear dates')), + ], + ), + 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', + ), + ], ), - 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), + 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 + ReadingActivityTimeline( + activities: visible, + provider: _provider, + timezone: timezone, + correctedIds: corrected, + range: range, ), - ], - ); - } - - 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( + const SizedBox(height: Spacing.lg), + if (filtered.isNotEmpty || archived.isNotEmpty) + ExpansionTile( + key: const PageStorageKey('goal-period-history'), + tilePadding: EdgeInsets.zero, + 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: [ - 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), + if (filtered.isNotEmpty) _cards(context, filtered, historical: true), + if (archived.isNotEmpty) ...[ + if (filtered.isNotEmpty) const SizedBox(height: Spacing.lg), + Align( + alignment: AlignmentDirectional.centerStart, + 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, currentDetails: true), + ], ], ), - ), - ); - } - - // ============================================================================ - // 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'), - ); + ]; } - // ============================================================================ - // 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, - ); - }, - ); - } - - void _showGoalDetails(BuildContext context, ReadingGoal goal) { - if (goal.isCompleted) { - CompletedGoalChip.showDetailsSheet(context, goal: goal, onDelete: () => _provider.deleteGoal(goal.id)); + 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)); 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); - }, - ); + 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); + 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()))); + } } + + Future _restore(ReadingGoal goal) => _provider.restoreGoal(goal.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..f92b7ecc 100644 --- a/app/lib/powersync/library_database.dart +++ b/app/lib/powersync/library_database.dart @@ -1,4 +1,9 @@ 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'; +import 'package:papyrus/models/goal_period_record.dart'; import 'package:papyrus/data/repositories/library_repository.dart'; import 'package:papyrus/models/book.dart'; @@ -8,10 +13,15 @@ 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; + int trackingSchemaVersion = 0; + bool get trackingSupported => trackingSchemaVersion > 0; + set trackingSupported(bool value) => trackingSchemaVersion = value ? 2 : 0; + @override + bool get isCurrent => active; LibraryDatabase(this.database, this.onWrite); @@ -152,7 +162,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 +197,109 @@ class LibraryDatabase implements LibraryMembershipWriter { ); }); + Future enableTracking({int schemaVersion = 2}) async { + final previous = trackingSchemaVersion; + try { + await write((tx) async { + 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, payload, force: true); + } + await tx.execute('DELETE FROM tracking_staging WHERE id = ?', [row['id']]); + } + } + }); + } catch (_) { + trackingSchemaVersion = previous; + rethrow; + } + } + + Future _trackingRow( + SqliteWriteContext tx, + String table, + Map payload, { + bool deleted = false, + bool force = false, + }) async { + final id = payload['id'] as String; + 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], + ); + 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..69033cbc 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -1,3 +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'; @@ -9,8 +11,16 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { final AuthRepository authRepository; final PapyrusApiConfig config; final Future Function()? onUploadComplete; + final bool Function()? supportsTracking; + final int Function()? trackingSchemaVersion; - PapyrusPowerSyncConnector({required this.authRepository, required this.config, this.onUploadComplete}); + PapyrusPowerSyncConnector({ + required this.authRepository, + required this.config, + this.onUploadComplete, + this.supportsTracking, + this.trackingSchemaVersion, + }); @override Future fetchCredentials() async { @@ -40,7 +50,26 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { return; } - final batch = powerSyncUploadBatchFromCrud(transaction.crud); + 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) { + 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..63a10a34 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,8 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { PowerSyncDatabase? _database; LibraryDatabase? _library; LibrarySnapshot? _snapshot; + bool get supportsTracking => _library?.trackingSupported == true; + int get trackingSchemaVersion => _library?.trackingSchemaVersion ?? 0; final _libraryController = StreamController.broadcast(); LibraryDatabase get _activeLibrary { @@ -103,7 +109,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 +140,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { } final database = _requireDatabase(); if (online) { + await _prepareTracking(); await database.connect(connector: connectorFactory()); } else { await database.disconnect(); @@ -143,6 +155,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { final database = _requireDatabase(); _watchStatus(database); await database.disconnect(); + await _prepareTracking(); await database.connect(connector: connectorFactory()); } @@ -263,11 +276,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 +291,35 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { await _refreshPendingWrites(); } + Future? _trackingPreparation; + LibraryDatabase? _trackingLibrary; + Future _prepareTracking() { + final library = _activeLibrary; + final pending = _trackingPreparation; + if (pending != null && identical(_trackingLibrary, library)) return pending; + final operation = _refreshTracking(library); + _trackingLibrary = library; + _trackingPreparation = operation; + return operation.whenComplete(() { + if (identical(_trackingPreparation, operation)) { + _trackingPreparation = null; + _trackingLibrary = null; + } + }); + } + + Future _refreshTracking(LibraryDatabase library) async { + try { + final version = await trackingCapability?.call() ?? 0; + if (library.active && version != library.trackingSchemaVersion) { + await library.enableTracking(schemaVersion: version); + } + } catch (_) { + // Discovery failure keeps tracking local and does not prevent library sync. + if (library.active) library.trackingSchemaVersion = 0; + } + } + void _watchBooks(PowerSyncDatabase database) { unawaited(_booksSubscription?.cancel()); final library = _activeLibrary; @@ -304,6 +348,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository { } Future _setStatusFromPowerSync(SyncStatus status) async { + if (status.connected && !_syncState.connected) 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/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..18db5c98 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -1,82 +1,114 @@ +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()); + // 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 _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 +116,260 @@ class GoalsProvider extends ChangeNotifier { bool isRecurring = true, DateTime? startDate, DateTime? endDate, + String? title, + GoalScope scope = GoalScope.library, + String? scopeId, + List bookIds = const [], + int minimumMinutes = 5, + String? timezone, + String? replaceGoalId, + TrackingRepository? repository, }) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); + 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.'); + 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.'); } - - 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); + 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 newGoal = ReadingGoal( - id: 'goal-${DateTime.now().millisecondsSinceEpoch}', + 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: 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)], + ); + await store.commitTracking( + goals: [if (replacing != null) _revisedGoal(replacing, archived: true, active: false), goal], + repository: origin, ); - - _dataStore!.addReadingGoal(newGoal); - } - - /// Deletes a goal by ID. Persists to DataStore. - Future deleteGoal(String goalId) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); - } - _dataStore!.deleteReadingGoal(goalId); } - /// Updates the progress of a goal. Persists to DataStore. - Future updateGoalProgress(String goalId, int newProgress) async { - if (_dataStore == null) { - throw Exception('DataStore not attached'); + 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) { + 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, + bookIds: goal.bookIds, + minimumMinutes: goal.minimumMinutes, + timezone: goal.timezone, + replaceGoalId: goalId, + repository: repository, + ); + return; } - - 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); + 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); } - /// 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'); - } + Future _revise( + ReadingGoal goal, { + int? target, + String? title, + bool? active, + bool? archived, + TrackingRepository? repository, + }) => 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 + ? [ + 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, + ); + return goal.copyWith( + targetValue: rule.target, + title: rule.title, + isActive: rule.active, + isArchived: rule.archived, + rules: [...rules, rule], + ); + } - 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 anotherCompletion = store.effectiveReadingActivities.any( + (activity) => activity.bookId == book.id && activity.kind == 'completion' && activity.id != correcting?.id, + ); + final updated = finished + ? book.copyWith(readingStatus: LibraryReadingStatus.completed, completedAt: end) + : correcting?.kind == 'completion' && !anotherCompletion + ? 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); + _store = null; + super.dispose(); } } diff --git a/app/lib/providers/statistics_provider.dart b/app/lib/providers/statistics_provider.dart index b609db54..2f082ffe 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,51 @@ 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 _completedBooks(range.start, range.end).length; + } + + Set _completedBooks(DateTime start, DateTime end) { + final store = _dataStore; + if (store == null) return {}; + final trackedBooks = store.readingActivities.where((a) => a.kind == 'completion').map((a) => a.bookId).toSet(); + return { + for (final activity in store.effectiveReadingActivities) + if (activity.kind == 'completion' && !activity.endTime.isBefore(start) && activity.endTime.isBefore(end)) + activity.bookId, + // Pre-ledger completion dates remain history without contributing to new goals. + for (final book in store.books) + if (!trackedBooks.contains(book.id) && + book.completedAt != null && + !book.completedAt!.isBefore(start) && + book.completedAt!.isBefore(end)) + book.id, + }; } /// 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 +192,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 +208,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 +348,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: _completedBooks(day, DateTime(day.year, day.month, day.day + 1)).toList(), ); }); } @@ -381,39 +436,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: _completedBooks(start, end).length, + 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..da1c5f6a 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -1,54 +1,74 @@ -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: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'; +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/goals/goal_controls.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'; -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, + this.initialTimezone, + this.initialMetric, + }); + final GoalsProvider provider; + final ReadingGoal? editing; + final int? preset; + final String? initialTimezone; + final GoalType? initialMetric; 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), + required GoalsProvider provider, + ReadingGoal? editing, + int? preset, + String? initialTimezone, + }) async { + GoalType? metric; + if (editing == null && preset == null) { + ModalBottomSheetRoute? chooserRoute; + metric = await showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + useSafeArea: true, + sheetAnimationStyle: AppMotion.animationStyle(context), + constraints: const BoxConstraints(maxWidth: 640), + builder: (sheetContext) { + chooserRoute = ModalRoute.of(sheetContext) as ModalBottomSheetRoute; + return const _GoalMetricSheet(); + }, + ); + // Wait for the chooser to leave before presenting the differently sized form. + await chooserRoute?.completed; + if (metric == null || !context.mounted) return; + } + if (!context.mounted) return; + await showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, useSafeArea: true, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), - builder: (context) => AddGoalSheet(onCreate: onCreate), + sheetAnimationStyle: AppMotion.animationStyle(context), + constraints: const BoxConstraints(maxWidth: 640), + builder: (_) => AddGoalSheet( + provider: provider, + editing: editing, + preset: preset, + initialTimezone: initialTimezone, + initialMetric: metric, + ), ); } @@ -57,410 +77,447 @@ class AddGoalSheet extends StatefulWidget { } 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; + List _bookIds = []; + bool _recurring = true; + bool _saving = false; + bool _timezoneReady = false; + String? _error; + DateTime _deadline = DateTime.now().add(const Duration(days: 30)); + @override + void initState() { + super.initState(); + _repository = widget.provider.store.trackingRepository; + _applyPreset(widget.preset ?? 0); + if (widget.initialMetric case final type?) { + _type = type; + _period = GoalPeriod.daily; + _target.text = switch (type) { + GoalType.books || GoalType.days => '1', + GoalType.minutes => '30', + GoalType.pages => '100', + }; + } + final editing = widget.editing; + if (editing != null) { + _type = editing.type; + _period = editing.period; + _scope = editing.scope; + _scopeId = editing.scopeId; + _bookIds = editing.selectedBookIds.toList(); + _recurring = editing.isRecurring; + _target.text = '${editing.targetValue}'; + _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!; + _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. Calendar periods will use UTC.'; + }); + } + }); + } + } - // Custom date range - DateTime _startDate = DateTime.now(); - DateTime _endDate = DateTime.now().add(const Duration(days: 30)); + void _applyPreset(int 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; + _bookIds = []; + _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; - 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), + final editing = widget.editing != null; + final colors = Theme.of(context).colorScheme; + final store = widget.provider.store; + 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 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: [ + TextFormField( + controller: _title, + enabled: !_saving, + maxLength: 255, + decoration: const InputDecoration(labelText: 'Name (optional)', counterText: ''), ), - ButtonSegment( - value: GoalScheduleType.oneOff, - label: Text('One-off'), - icon: Icon(Icons.looks_one, size: 18), + 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(_metricLabel(type))), + ], + onChanged: _saving + ? null + : (value) => setState(() { + _type = value!; + if (_type == GoalType.books && _bookIds.isNotEmpty) _target.text = '${_bookIds.length}'; + }), ), - ButtonSegment( - value: GoalScheduleType.custom, - label: Text('Custom'), - icon: Icon(Icons.date_range, size: 18), + 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; + }, + ), ), - ], - 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), - ), - 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.lg), + Text('Schedule', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: Spacing.sm), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + for (final period in GoalPeriod.values) + ChoiceChip( + 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; + }), + ), + ], + ), + 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}'), + ), ), ), - ], - ), - ), - 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 - TextFormField( - controller: _targetController, - 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), + 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', + }), + ), + ], + onChanged: _saving + ? null + : (value) => setState(() { + _scope = value!; + _scopeId = null; + _bookIds = []; + }), ), - ), - 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); - }, - ), - 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), - ), + 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}'; + }), ), - const SizedBox(width: Spacing.md), - Expanded( - child: _buildDateButton(context, label: 'End', date: _endDate, onTap: () => _pickEndDate(context)), + ], + 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; + }, + ), ), ], - ), - 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), - ), - const SizedBox(width: Spacing.md), - Text( - formatDuration(_durationMinutes), - style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(width: Spacing.md), - IconButton( - onPressed: () { - setState(() { - final step = _durationMinutes >= 60 ? 15 : 5; - _durationMinutes += step; - }); - }, - icon: const Icon(Icons.add), - ), + 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), + child: Text(_error!, style: TextStyle(color: colors.error)), + ), ], ), ), - ], - ); - } - - 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), - ], - ), - ], + footer: BottomSheetFormActions( + onCancel: _saving ? null : () => Navigator.pop(context), + onSave: _saving || !_timezoneReady ? null : _save, + saveLabel: _saving + ? 'Saving…' + : editing + ? (_replacementNeeded ? 'Replace goal' : '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; - } + String get _typeLabel => switch (_type) { + GoalType.books => 'books', + GoalType.pages => 'pages', + GoalType.minutes => 'minutes', + GoalType.days => 'days', + }; - 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'; - } - } + 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), + ); - 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)'; - } - } + Widget _compact({required Widget child, double width = 220}) => Align( + alignment: AlignmentDirectional.centerStart, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: width), + child: child, + ), + ); - String _getTypeSuffix(GoalType type) { - switch (type) { - case GoalType.books: - return 'books'; - case GoalType.pages: - return 'pages'; - case GoalType.minutes: - return 'minutes'; - } + 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 || + (_scope == GoalScope.book + ? !setEquals(_bookIds.toSet(), goal.selectedBookIds.toSet()) + : _scopeId != goal.scopeId) || + _recurring != goal.isRecurring || + zone != goal.timezone || + int.tryParse(_threshold.text) != goal.minimumMinutes || + deadlineChanged; } - String _getDefaultTarget(GoalType type) { - switch (type) { - case GoalType.books: - return '12'; - case GoalType.pages: - return '50'; - case GoalType.minutes: - return '30'; + Future _save() async { + if (!_form.currentState!.validate()) return; + setState(() { + _saving = true; + _error = null; + }); + try { + if (widget.editing != null && !_replacementNeeded) { + await widget.provider.updateGoal( + goalId: widget.editing!.id, + target: int.parse(_target.text), + title: _title.text.trim(), + repository: _repository, + ); + } else { + await widget.provider.createGoal( + replaceGoalId: widget.editing?.id, + type: _type, + target: int.parse(_target.text), + period: _period, + isRecurring: _recurring, + 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, + repository: _repository, + ); + } + if (mounted) Navigator.pop(context); + } catch (error) { + if (mounted) { + setState(() { + _saving = false; + _error = error.toString(); + }); + } } } +} - 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 _metricLabel(GoalType type) => switch (type) { + GoalType.books => 'Books finished', + GoalType.pages => 'Pages read', + GoalType.minutes => 'Reading time', + GoalType.days => 'Reading days', +}; - 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}'; - } +class _GoalMetricSheet extends StatelessWidget { + const _GoalMetricSheet(); - int _daysBetween() { - return _endDate.difference(_startDate).inDays + 1; + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return GoalControls( + child: AppBottomSheet( + title: 'New goal', + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + 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: () => Navigator.pop(context, type), + ), + ), + ), + ], + ), + footer: BottomSheetActions( + primary: OutlinedButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + ), + ), + ); } } 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..f7020b72 100644 --- a/app/lib/widgets/goals/goal_card.dart +++ b/app/lib/widgets/goals/goal_card.dart @@ -1,51 +1,209 @@ 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 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 { - /// The reading goal to display. + const GoalCard({ + super.key, + required this.goal, + this.progress, + this.onTap, + this.onMenu, + this.scopeLabel, + this.isDesktop = false, + this.fillHeight = false, + this.showReadingDays = false, + }); final ReadingGoal goal; - - /// Called when the card is tapped. + final GoalProgress? progress; final VoidCallback? onTap; - - /// Whether to use desktop styling. + final void Function(String)? onMenu; + final String? scopeLabel; final bool isDesktop; - - const GoalCard({super.key, required this.goal, this.onTap, this.isDesktop = false}); - + final bool fillHeight; + final bool showReadingDays; @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( + semanticContainer: false, + 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: fillHeight ? MainAxisSize.max : MainAxisSize.min, children: [ - _buildHeader(context, colorScheme, textTheme), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 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: 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 goal')), + 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.md), + Text( + progress == null + ? '${projected.currentValue} / ${projected.targetValue} ${projected.unitLabel(projected.targetValue)}' + : goalCount(progress!), + style: texts.titleMedium, + ), + 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}, ${progress == null ? '${projected.currentValue} of ${projected.targetValue} ${projected.unitLabel(projected.targetValue)}' : goalCount(progress!)}', + child: LinearProgressIndicator( + value: progress?.fraction ?? projected.progress, + minHeight: 6, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), const SizedBox(height: Spacing.md), - _buildProgressValues(context, colorScheme, textTheme), - const SizedBox(height: Spacing.sm), - _buildProgressBar(colorScheme), - const SizedBox(height: Spacing.sm), - _buildFooter(colorScheme, textTheme), + 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, + ), + if (!projected.isCompleted) + Text(progress == null ? projected.statusText : goalRemaining(progress!), style: texts.bodySmall), + ], + ), + const SizedBox(height: Spacing.xs), + 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 (progress?.estimated == true && goal.type == GoalType.pages) + Text('Estimated pages included', style: texts.bodySmall), + if (showReadingDays && 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 (fillHeight) const Spacer(), ], ), ), @@ -53,214 +211,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_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 new file mode 100644 index 00000000..fa4f34a6 --- /dev/null +++ b/app/lib/widgets/goals/goal_details_sheet.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.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/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/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}); + 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(); + final canRestore = goal.isArchived && widget.provider.store.getReadingGoal(goal.id) != null; + return GoalControls( + child: AppBottomSheet( + title: 'Goal details', + canClose: !_saving, + onClose: () => Navigator.pop(context), + contentPadding: const EdgeInsets.fromLTRB(Spacing.lg, Spacing.lg, Spacing.lg, Spacing.md), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _progressSummary(context, progress), + if (canRestore || (widget.historical == null && !goal.isArchived)) ...[ + const SizedBox(height: Spacing.md), + Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: [ + if (canRestore) + OutlinedButton.icon( + onPressed: _saving + ? null + : () => _action(() => widget.provider.restoreGoal(goal.id, repository: _repository)), + icon: const Icon(Icons.unarchive_outlined), + label: const Text('Restore goal'), + ) + else ...[ + 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'), + ), + ], + ], + ), + ], + const SizedBox(height: Spacing.sm), + Text('Reading activity', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: Spacing.sm), + if (progress.activities.isEmpty) + 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), + 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( + equalWidths: true, + onCancel: _saving || widget.provider.store.getReadingGoal(goal.id) == null ? null : _delete, + cancelLabel: 'Delete', + onSave: _saving ? null : () => Navigator.pop(context), + saveLabel: 'Done', + ), + ), + ); + }, + ); + 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; + _error = null; + }); + try { + await action(); + } catch (error) { + _error = error.toString(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + 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( + 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')), + ], + ), + ) ?? + false; 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 new file mode 100644 index 00000000..32332ab3 --- /dev/null +++ b/app/lib/widgets/goals/log_reading_sheet.dart @@ -0,0 +1,594 @@ +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/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}); + 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 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: [ + 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), + Wrap( + spacing: Spacing.md, + runSpacing: Spacing.sm, + children: [ + _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)), + ), + ), + _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)), + ), + ), + ], + ), + 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, + ), + ), + ], + ); + }, + ), + 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, + textAlignVertical: TextAlignVertical.top, + decoration: const InputDecoration(labelText: 'Note (optional)', alignLabelWithHint: true), + ), + + 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', + ), + ), + ); + } + + 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!); + 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(); + }); + } + } + } +} + +/// 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({ + super.key, + 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; + @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 (index, group) in groups.values.indexed) ...[ + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 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, + contributionLabel: contributionLabel?.call(activity), + ), + ], + ], + ); + } +} + +class ReadingActivityTile extends StatelessWidget { + const ReadingActivityTile({ + super.key, + required this.activity, + required this.provider, + this.corrected = false, + this.showBookTitle = true, + this.contributionLabel, + }); + final String? contributionLabel; + final ReadingActivity activity; + final GoalsProvider provider; + final bool corrected; + final bool showBookTitle; + @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, + ), + dense: !showBookTitle, + title: Text( + showBookTitle + ? activity.bookTitle + : '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}', + maxLines: showBookTitle ? 2 : null, + overflow: showBookTitle ? TextOverflow.ellipsis : null, + ), + subtitle: showBookTitle + ? Text( + '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}\n${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', + ) + : contributionLabel == null + ? null + : Text(contributionLabel!), + trailing: showBookTitle + ? null + : Text(DateFormat.Hm().format(activity.startTime.toLocal()), style: Theme.of(context).textTheme.bodySmall), + isThreeLine: showBookTitle, + 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 GoalControls( + child: AppBottomSheet( + title: 'Reading activity', + canClose: !_saving, + onClose: () => Navigator.pop(context), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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: 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, + 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/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..b2cf6606 --- /dev/null +++ b/app/lib/widgets/goals/reading_activity_timeline.dart @@ -0,0 +1,173 @@ +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( + // 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(), + 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/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/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..c606b14d 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: "8b60a1ece7103f0751776e416d22639b1e0df950" + resolved-ref: "8b60a1ece7103f0751776e416d22639b1e0df950" 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..701ecbbd 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: 8b60a1ece7103f0751776e416d22639b1e0df950 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..70806220 --- /dev/null +++ b/app/test/goals/fixtures/projections.json @@ -0,0 +1,966 @@ +[ + { + "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 + } + }, + { + "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 new file mode 100644 index 00000000..94bceb9e --- /dev/null +++ b/app/test/goals/goal_progress_test.dart @@ -0,0 +1,146 @@ +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('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'); + 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..49cbe2da --- /dev/null +++ b/app/test/goals/goals_provider_test.dart @@ -0,0 +1,202 @@ +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('correcting duplicate completions only clears status after the last confirmation', () 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.books, target: 1, period: GoalPeriod.daily, timezone: 'UTC'); + now = now.add(const Duration(hours: 1)); + await provider.logReading(book: store.getBook(book.id)!, end: now, finished: true); + now = now.add(const Duration(minutes: 1)); + await provider.logReading(book: store.getBook(book.id)!, end: now, finished: true); + final confirmations = store.effectiveReadingActivities.where((a) => a.kind == 'completion').toList(); + final completedAt = store.getBook(book.id)!.completedAt; + await provider.logReading(book: store.getBook(book.id)!, end: now, minutes: 30, correcting: confirmations.first); + expect(store.getBook(book.id)!.readingStatus, LibraryReadingStatus.completed); + expect(store.getBook(book.id)!.completedAt, completedAt); + expect(provider.current.single.finishedBooks, 1); + await provider.logReading(book: store.getBook(book.id)!, end: now, minutes: 30, correcting: confirmations.last); + expect(store.getBook(book.id)!.readingStatus, LibraryReadingStatus.inProgress); + expect(store.getBook(book.id)!.completedAt, isNull); + expect(provider.current.single.finishedBooks, 0); + }); + 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('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); + 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/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 2c9a579c..6631951f 100644 --- a/app/test/pages/goals_page_test.dart +++ b/app/test/pages/goals_page_test.dart @@ -2,26 +2,611 @@ 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/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; 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 { + 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, closeTo(size.height / 2, 100)); + 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); + }); + } + 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 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')); + await tester.pumpAndSettle(); + expect(find.text('Missed'), findsWidgets); + expect(tester.takeException(), isNull); + }); + testWidgets('archived goals appear once below their heading with periods available in details', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 2400); + addTearDown(tester.view.reset); + final now = DateTime.now().toUtc(); + final store = DataStore() + ..loadData( + readingGoals: [ + for (final id in ['first', 'second']) + ReadingGoal( + id: id, + title: 'Archived $id', + type: GoalType.books, + targetValue: 1, + period: GoalPeriod.daily, + createdAt: now.subtract(const Duration(days: 3)), + startDate: now.subtract(const Duration(days: 3)), + endDate: now.subtract(const Duration(days: 2)), + isActive: false, + isArchived: true, + ), + ], + ); + 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(); + await tester.tap(find.text('Goal history')); + await tester.pumpAndSettle(); + final heading = tester.getRect(find.text('Archived goals')); + expect(find.byType(GoalCard), findsNWidgets(2)); + for (final id in ['first', 'second']) { + expect(find.text('Archived $id'), findsOneWidget); + expect(tester.getRect(find.text('Archived $id')).top, greaterThan(heading.bottom)); + } + await tester.tap(find.text('Archived first')); + await tester.pumpAndSettle(); + expect(find.text('Previous periods'), findsOneWidget); + expect(find.text('Restore goal'), findsOneWidget); + expect(find.textContaining('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 { + 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(); + 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 + .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); + 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..bd221fbb --- /dev/null +++ b/app/test/powersync/tracking_persistence_test.dart @@ -0,0 +1,266 @@ +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/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'; +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('reconnect refreshes cached capabilities after upgrade, downgrade and discovery failure', () async { + var version = 2; + var failDiscovery = false; + final db = PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + trackingCapability: () async { + if (failDiscovery) throw StateError('Server unavailable'); + return version; + }, + pathResolver: (mode, profile, user) async => '${directory.path}/refresh.db', + ); + addTearDown(db.close); + await db.activateAuthenticated('one'); + await db.reconnect(); + expect(db.trackingSchemaVersion, 2); + await db.upsert(book); + await db.trackingRepository.commitTracking(goals: [goal], activities: [activity]); + for (final supported in [0, 1, 2]) { + version = supported; + await db.reconnect(); + expect(db.trackingSchemaVersion, supported); + expect(db.supportsTracking, supported > 0); + final snapshot = await db.watchLibrary().firstWhere((s) => s.activities.isNotEmpty && s.books.isNotEmpty); + expect(snapshot.activities.single.id, activity.id); + expect(snapshot.books.single.id, book.id); + } + failDiscovery = true; + await db.reconnect(); + expect(db.trackingSchemaVersion, 0); + expect((await db.watchLibrary().firstWhere((s) => s.activities.isNotEmpty)).activities.single.id, activity.id); + }); + 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('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(); + 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/providers/statistics_provider_test.dart b/app/test/providers/statistics_provider_test.dart new file mode 100644 index 00000000..9d8c710d --- /dev/null +++ b/app/test/providers/statistics_provider_test.dart @@ -0,0 +1,61 @@ +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/statistics_provider.dart'; +import 'package:papyrus/providers/enums/library_reading_status.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + test('historical completion dates survive the ledger upgrade without resurrecting undone entries', () async { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final previousMonth = DateTime(now.year, now.month - 1, 15); + Book completed(String id, DateTime at) => Book( + id: id, + title: id, + author: '', + addedAt: at, + readingStatus: LibraryReadingStatus.completed, + completedAt: at, + ); + final store = DataStore() + ..loadData( + books: [ + completed('legacy-today', today), + completed('legacy-previous', previousMonth), + completed('tracked', today), + completed('undone', today), + ], + ); + addTearDown(store.dispose); + ReadingActivity completion(String id, String bookId) => ReadingActivity( + id: id, + bookId: bookId, + bookTitle: bookId, + startTime: today, + endTime: today, + createdAt: today, + kind: 'completion', + ); + final undone = completion('undone-entry', 'undone'); + await store.commitTracking( + activities: [ + completion('tracked-one', 'tracked'), + completion('tracked-two', 'tracked'), + undone, + store.reversalFor(undone), + ], + ); + final provider = StatisticsProvider()..attach(store); + addTearDown(provider.dispose); + provider.setPeriod(StatsPeriod.allTime); + expect(provider.totalBooks, 3); + expect(provider.monthlyStats.last.booksRead, 2); + expect(provider.monthlyStats[10].booksRead, 1); + expect(provider.readingTimeData.last.booksRead, unorderedEquals(['legacy-today', 'tracked'])); + provider.setCustomDateRange(previousMonth, previousMonth); + expect(provider.totalBooks, 1); + expect(store.readingActivities.where((a) => a.bookId.startsWith('legacy')), isEmpty); + }); +} 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..2f5f1ce4 100644 --- a/app/test/widgets/goals/goal_details_sheets_test.dart +++ b/app/test/widgets/goals/goal_details_sheets_test.dart @@ -1,77 +1,316 @@ 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/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/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/goals/add_goal_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 { + 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); + 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('creates 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 = Size(width, 700); - tester.view.padding = const FakeViewPadding(bottom: 48); - tester.view.viewPadding = const FakeViewPadding(bottom: 48); + 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.dark, + theme: AppTheme.light, 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'), + onPressed: () => AddGoalSheet.show(context, provider: provider, editing: goal), + child: const Text('Edit'), ), ), ), ), ); - await tester.tap(find.text('Open')); + 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 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); + await tester.ensureVisible(find.text('Archive goal')); + await tester.tap(find.text('Archive goal')); + await tester.pumpAndSettle(); + expect(store.getReadingGoal(goal.id)!.isArchived, isTrue); + final restore = find.widgetWithText(OutlinedButton, 'Restore goal'); + expect(restore, findsOneWidget); + await tester.ensureVisible(restore); + await tester.tap(restore); + await tester.pumpAndSettle(); + expect(store.getReadingGoal(goal.id)!.isArchived, isFalse); + expect(find.text('Restore goal'), findsNothing); + expect(find.text('Edit goal'), findsOneWidget); + expect(store.readingActivities, hasLength(1)); + expect(tester.takeException(), isNull); + }); } - - 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); + 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(); final done = find.widgetWithText(FilledButton, 'Done'); - final delete = find.widgetWithText(OutlinedButton, completed ? 'Delete goal' : 'Delete'); + 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(tester.getRect(delete).height, tester.getRect(done).height); + 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(); 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/goals/goals_ui_refinement_test.dart b/app/test/widgets/goals/goals_ui_refinement_test.dart new file mode 100644 index 00000000..169af9f2 --- /dev/null +++ b/app/test/widgets/goals/goals_ui_refinement_test.dart @@ -0,0 +1,598 @@ +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'; +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(); + 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, {ThemeData? theme}) async { + await size(tester); + await tester.pumpWidget( + MaterialApp( + theme: 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 goal cards without reader launch controls', (tester) async { + 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.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)); + 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('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: 'Europe/Vilnius'), + ); + 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(); + 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.byTooltip('Goal actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + 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); + }); + + 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); + }); + } + + 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')); + final chooser = find.widgetWithText(ListTile, metric); + final chooserRoute = ModalRoute.of(tester.element(chooser))!; + await tester.tap(chooser); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + // The selector keeps its content while exiting; the form appears only + // after that route finishes, rather than resizing the current sheet. + expect(find.byKey(const Key('goal-target-input')), findsNothing); + expect(chooser, findsOneWidget); + expect(chooserRoute.animation!.status, AnimationStatus.reverse); + await tester.pumpAndSettle(); + final formRoute = ModalRoute.of(tester.element(find.byKey(const Key('goal-target-input'))))!; + expect(formRoute, isNot(same(chooserRoute))); + expect(chooserRoute.isCurrent, isFalse); + expect(formRoute.isCurrent, isTrue); + 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('e-ink reopens goal creation without animation', (tester) async { + final store = DataStore()..loadData(); + final goals = provider(store); + await showSheet( + tester, + (context) => AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC'), + theme: AppTheme.eink, + ); + final chooser = find.widgetWithText(ListTile, 'Reading time'); + final chooserRoute = ModalRoute.of(tester.element(chooser))!; + await tester.tap(chooser); + await tester.pumpAndSettle(); + final formRoute = ModalRoute.of(tester.element(find.byKey(const Key('goal-target-input'))))!; + expect(formRoute, isNot(same(chooserRoute))); + expect(formRoute.transitionDuration, Duration.zero); + expect(formRoute.reverseTransitionDuration, Duration.zero); + expect(find.widgetWithText(ListTile, 'Books finished'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('dismissing the metric chooser does not open a form', (tester) async { + final store = DataStore()..loadData(); + final goals = provider(store); + var finished = false; + await showSheet(tester, (context) { + AddGoalSheet.show(context, provider: goals, initialTimezone: 'UTC').then((_) => finished = true); + }); + await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(finished, isTrue); + expect(find.byKey(const Key('goal-target-input')), findsNothing); + expect(find.byType(ListTile), findsNothing); + expect(store.goalDefinitions, isEmpty); + 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); + 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?'), 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'); + 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); + }); + + 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/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/app/test/widgets/shared/bottom_sheet_expansion_test.dart b/app/test/widgets/shared/bottom_sheet_expansion_test.dart index bf2dfc19..905492ea 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), preset: 0, 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), preset: 0, 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..2e0a72e1 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,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), + '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), 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..db73954d --- /dev/null +++ b/docs/goals-tracking.md @@ -0,0 +1,116 @@ +# 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 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. + +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: 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 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. +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 00000000..652a63e1 Binary files /dev/null and b/docs/images/goals-desktop.png differ diff --git a/docs/images/goals-mobile.png b/docs/images/goals-mobile.png new file mode 100644 index 00000000..7e6e5e5f Binary files /dev/null and b/docs/images/goals-mobile.png differ diff --git a/docs/images/goals-review/activity-desktop.png b/docs/images/goals-review/activity-desktop.png new file mode 100644 index 00000000..0a65e8b7 Binary files /dev/null and b/docs/images/goals-review/activity-desktop.png differ diff --git a/docs/images/goals-review/new-goal.png b/docs/images/goals-review/new-goal.png new file mode 100644 index 00000000..ec01cf45 Binary files /dev/null and b/docs/images/goals-review/new-goal.png differ diff --git a/docs/images/goals-review/overview-desktop.png b/docs/images/goals-review/overview-desktop.png new file mode 100644 index 00000000..7a7284b0 Binary files /dev/null and b/docs/images/goals-review/overview-desktop.png differ diff --git a/docs/images/goals-review/overview-mobile.png b/docs/images/goals-review/overview-mobile.png new file mode 100644 index 00000000..b0a1d2dd Binary files /dev/null and b/docs/images/goals-review/overview-mobile.png differ