diff --git a/AGENTS.md b/AGENTS.md index 486bc944..54a39d10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,23 @@ The Flutter package is `app/`, not the repository root. Commands below run from ## Implementation +### Coding style + +When working in the Papyrus workspace, read `../CODING_STYLE.md` for the +maintainer's reviewed examples. In standalone checkouts, follow these same rules: + +- Use 120-character formatter width and `trailing_commas: preserve`. Use trailing commas to keep deliberate multiline layouts. +- Put blank lines around blocks and multiline statements when adjacent code exists. Keep consecutive single-line statements together, even when their purpose changes. Do not pad the start or end of a block. +- Use meaningful local and callback names such as `activity`, `document`, `error`, and comparator operands `left`/`right`. Avoid redundant qualifiers. +- Use braces for ordinary guards and conditional side effects. A compact consecutive table of immediate returns can remain inline when it is easier to scan. +- Replace nested ternaries with clear branches or a switch. Simple ternaries, getters, and expression-bodied methods remain appropriate. +- Extract meaningful constants and focused helpers for distracting details. Preserve scope, return values, null handling, types, and behavior. +- Remove comments that restate obvious code and decorative section banners. Retain explanations of non-obvious constraints and contracts. +- Keep related declarations together. Simple interface signatures need no blank lines between each member; multiline declarations and larger groups should be separated. +- Apply these rules to changed code without broad unrelated reformatting. + +### Behavior and integration + Keep business logic in existing services, repositories and providers. Reuse shared widgets and theme tokens. Preserve e-ink behavior, keyboard/focus handling, compact and wide layouts. Use existing platform adapters and conditional imports; do not diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml index 84bc6268..bb798953 100644 --- a/app/analysis_options.yaml +++ b/app/analysis_options.yaml @@ -5,6 +5,7 @@ linter: lines_longer_than_80_chars: false formatter: page_width: 120 + trailing_commas: preserve analyzer: exclude: - build/** diff --git a/app/integration_test/powersync_books_integration_test.dart b/app/integration_test/powersync_books_integration_test.dart index 62c6c054..b09a653f 100644 --- a/app/integration_test/powersync_books_integration_test.dart +++ b/app/integration_test/powersync_books_integration_test.dart @@ -29,13 +29,17 @@ class MemoryRefreshTokenStorage implements RefreshTokenStorage { Future waitForBook(PapyrusPowerSyncService service, String id, {bool present = true, String? title}) async { final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (DateTime.now().isBefore(deadline)) { final book = await service.getById(id); + if ((book != null) == present && (title == null || book?.title == title)) { return; } + await Future.delayed(const Duration(milliseconds: 200)); } + fail('Book $id did not reach expected presence=$present'); } @@ -64,15 +68,18 @@ void main() { } final firstAuth = repository('first'); + final firstTokens = await firstAuth.register( email: email, password: password, displayName: 'PowerSync Test', clientType: 'desktop', ); + final secondAuth = repository('second'); await secondAuth.login(email: email, password: password, clientType: 'desktop'); final otherAuth = repository('other'); + final otherTokens = await otherAuth.register( email: 'other-$email', password: password, @@ -90,6 +97,7 @@ void main() { second.activateAuthenticated(firstTokens.user.userId), other.activateAuthenticated(otherTokens.user.userId), ]); + await Future.wait([ first.syncStates.firstWhere((state) => state.connected), second.syncStates.firstWhere((state) => state.connected), @@ -102,16 +110,15 @@ void main() { author: 'Papyrus', addedAt: DateTime.now().toUtc(), ); + await first.upsert(book); await waitForBook(second, book.id, title: book.title); expect(await other.getById(book.id), isNull); - await first.setOnline(false); await first.upsert(book.copyWith(title: 'Offline edit')); await first.setOnline(true); await waitForBook(second, book.id, title: 'Offline edit'); expect((await second.getById(book.id))?.title, 'Offline edit'); - await second.delete(book.id); await waitForBook(first, book.id, present: false); } finally { diff --git a/app/integration_test/reading_tracking_test.dart b/app/integration_test/reading_tracking_test.dart index 2cc3c8a0..3e33016c 100644 --- a/app/integration_test/reading_tracking_test.dart +++ b/app/integration_test/reading_tracking_test.dart @@ -8,50 +8,69 @@ 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; + + 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 (app.store.readingActivities.any((activity) => activity.bookId == format && activity.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') + .where((activity) => activity.bookId == format && activity.kind == 'reading') .toList(); + expect(activity, isNotEmpty); - expect(activity.fold(0, (n, a) => n + a.seconds), greaterThanOrEqualTo(10)); - expect(activity.expand((a) => a.coverage), isNotEmpty); + expect(activity.fold(0, (total, activity) => total + activity.seconds), greaterThanOrEqualTo(10)); + expect(activity.expand((activity) => activity.coverage), isNotEmpty); } + final totals = app.goals.current; - expect(totals.firstWhere((p) => p.goal.type == GoalType.minutes).seconds, greaterThanOrEqualTo(20)); + expect(totals.firstWhere((item) => item.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(), + 'sources': app.store.readingActivities.map((activity) => activity.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 index a27b1765..a783d29e 100644 --- a/app/integration_test/support/tracking_app.dart +++ b/app/integration_test/support/tracking_app.dart @@ -24,6 +24,7 @@ import 'package:powersync/powersync.dart' hide Column; class NoNetworkConnector extends PowerSyncBackendConnector { @override Future fetchCredentials() async => null; + @override Future uploadData(PowerSyncDatabase database) async {} } @@ -31,6 +32,7 @@ class NoNetworkConnector extends PowerSyncBackendConnector { class FixtureMediaCache extends MediaCacheService { FixtureMediaCache(this.bytes); final Map bytes; + @override Future ensureBookFileCached( Book book, { @@ -43,6 +45,7 @@ class FixtureMediaCache extends MediaCacheService { class FixtureAuth extends ChangeNotifier implements AuthProvider { @override Future downloadMedia(String assetId) async => Uint8List(0); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -55,21 +58,31 @@ class TrackingValidationApp { 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'); + + 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( @@ -84,6 +97,7 @@ class TrackingValidationApp { ), ); } + store = DataStore(bookRepository: database); await store.waitUntilLoaded(); goals = GoalsProvider(watchClock: false)..attach(store); @@ -93,6 +107,7 @@ class TrackingValidationApp { final prefs = await SharedPreferences.getInstance(); await ReadingDeviceIdentity.initialize(prefs); preferences = PreferencesProvider(prefs); + router = GoRouter( routes: [ GoRoute( @@ -142,6 +157,7 @@ class TrackingValidationApp { ), ), ); + Future close() async { goals.dispose(); router.dispose(); diff --git a/app/lib/acquisition/acquisition_api_client.dart b/app/lib/acquisition/acquisition_api_client.dart index 40608f68..75eac460 100644 --- a/app/lib/acquisition/acquisition_api_client.dart +++ b/app/lib/acquisition/acquisition_api_client.dart @@ -25,6 +25,7 @@ class AcquisitionApiClient { config.endpoint('/acquisition/capabilities'), headers: _headers(accessToken), ); + return AcquisitionCapabilities.fromJson(_decodeObject(response)); } @@ -56,6 +57,7 @@ class AcquisitionApiClient { 'password': ?password, }), ); + return AcquisitionEndpoint.fromJson(_decodeObject(response)); } @@ -83,6 +85,7 @@ class AcquisitionApiClient { 'enabled': ?enabled, }), ); + return AcquisitionEndpoint.fromJson(_decodeObject(response)); } @@ -107,7 +110,9 @@ class AcquisitionApiClient { 'password': ?password, }), ); + final result = _decodeObject(response); + if (result['ok'] != true) { throw const AuthApiException(statusCode: 502, message: 'Connection test returned an invalid response'); } @@ -118,7 +123,11 @@ class AcquisitionApiClient { config.endpoint('/acquisition/endpoints/$endpointId'), headers: _headers(accessToken), ); - if (response.statusCode >= 200 && response.statusCode < 300) return; + + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + _decodeObject(response); } @@ -132,6 +141,7 @@ class AcquisitionApiClient { headers: _headers(accessToken), body: jsonEncode({'query': query, 'endpoint_ids': ?endpointIds}), ); + return _decodeList(response).map(TorrentRelease.fromJson).toList(); } @@ -148,6 +158,7 @@ class AcquisitionApiClient { 'release_tokens': releases.map((release) => release.releaseToken).toList(), }), ); + return BatchSubmissionResponse.fromJson(_decodeObject(response)); } @@ -172,13 +183,11 @@ class AcquisitionApiClient { Future listJobs({required String accessToken, int limit = 50, int offset = 0}) async { final uri = config.endpoint('/acquisition/jobs').replace(queryParameters: {'limit': '$limit', 'offset': '$offset'}); final response = await _httpClient.get(uri, headers: _headers(accessToken)); - return AcquisitionJobPage.fromJson(_decodeObject(response)); } Future getJob({required String accessToken, required String jobId}) async { final response = await _httpClient.get(config.endpoint('/acquisition/jobs/$jobId'), headers: _headers(accessToken)); - return AcquisitionJob.fromJson(_decodeObject(response)); } @@ -247,6 +256,7 @@ class AcquisitionApiClient { headers: _headers(accessToken), body: jsonEncode({'command': command, 'ids': ids}), ); + return AcquisitionJob.fromJson(_decodeObject(response)); } @@ -258,16 +268,21 @@ class AcquisitionApiClient { Map _decodeObject(http.Response response) { final decoded = response.body.isEmpty ? {} : jsonDecode(response.body) as Map; - if (response.statusCode >= 200 && response.statusCode < 300) return decoded; + + if (response.statusCode >= 200 && response.statusCode < 300) { + return decoded; + } + final error = decoded['error']; final detail = decoded['detail']; + throw AuthApiException( statusCode: response.statusCode, - message: error is Map - ? error['message'] as String? ?? 'Acquisition request failed' - : detail is String - ? detail - : 'Acquisition request failed', + message: switch (error is Map) { + true => error['message'] as String? ?? 'Acquisition request failed', + false when detail is String => detail, + false => 'Acquisition request failed', + }, ); } @@ -275,6 +290,7 @@ class AcquisitionApiClient { if (response.statusCode < 200 || response.statusCode >= 300) { _decodeObject(response); } + return (jsonDecode(response.body) as List).cast>(); } } diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index a49777f5..78e6abcd 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -81,7 +81,6 @@ class AuthApiClient { Future refresh(String refreshToken) async { final json = await _postJson(config.endpoint('/auth/refresh'), body: {'refresh_token': refreshToken}); - return AuthTokens.fromJson(json); } @@ -100,7 +99,6 @@ class AuthApiClient { Future currentUser(String accessToken) async { final json = await _getJson(config.endpoint('/users/me'), accessToken: accessToken); - return PapyrusUser.fromJson(json); } @@ -116,37 +114,31 @@ class AuthApiClient { } final json = await _patchJson(config.endpoint('/users/me'), accessToken: accessToken, body: body); - return PapyrusUser.fromJson(json); } Future forgotPassword(String email) async { final json = await _postJson(config.endpoint('/auth/forgot-password'), body: {'email': email}); - return json['message'] as String? ?? 'If the email is registered, a reset link has been sent'; } Future resetPassword({required String token, required String password}) async { final json = await _postJson(config.endpoint('/auth/reset-password'), body: {'token': token, 'password': password}); - return json['message'] as String? ?? 'Password has been reset successfully'; } Future verifyEmail(String token) async { final json = await _postJson(config.endpoint('/auth/verify-email'), body: {'token': token}); - return json['message'] as String? ?? 'Email verified successfully'; } Future resendVerification(String email) async { final json = await _postJson(config.endpoint('/auth/resend-verification'), body: {'email': email}); - return json['message'] as String? ?? 'If the email is registered, a verification link has been sent'; } Future powerSyncToken(String accessToken) async { final json = await _postJson(config.endpoint('/auth/powersync-token'), accessToken: accessToken); - return PowerSyncToken.fromJson(json); } @@ -179,24 +171,27 @@ class AuthApiClient { Future downloadMedia(String accessToken, String assetId) async { final response = await _httpClient.get(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken)); + if (response.statusCode >= 200 && response.statusCode < 300) { return response.bodyBytes; } + _decodeResponse(response); throw const AuthApiException(statusCode: 0, message: 'Media download failed'); } Future deleteMedia(String accessToken, String assetId) async { final response = await _httpClient.delete(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken)); + if (response.statusCode >= 200 && response.statusCode < 300) { return; } + _decodeResponse(response); } Future> _getJson(Uri uri, {String? accessToken}) async { final response = await _httpClient.get(uri, headers: _headers(accessToken)); - return _decodeResponse(response); } @@ -216,7 +211,6 @@ class AuthApiClient { required Map body, }) async { final response = await _httpClient.patch(uri, headers: _headers(accessToken), body: jsonEncode(body)); - return _decodeResponse(response); } @@ -234,7 +228,11 @@ class AuthApiClient { MediaType _mediaType(String contentType) { final parts = contentType.split('/'); - if (parts.length != 2) return MediaType('application', 'octet-stream'); + + if (parts.length != 2) { + return MediaType('application', 'octet-stream'); + } + return MediaType(parts[0], parts[1]); } diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index d074a295..e6644da6 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -100,11 +100,11 @@ class AuthRepository { Future signInWithGoogle({required String clientType, String? deviceLabel}) async { await apiClient.ensureServerReachable(); - final redirectUri = kIsWeb - ? _webOAuthRedirectUri() - : _usesDesktopLoopbackOAuth - ? desktopOAuthRedirectUri - : nativeOAuthRedirectUri; + final redirectUri = switch (kIsWeb) { + true => _webOAuthRedirectUri(), + false when _usesDesktopLoopbackOAuth => desktopOAuthRedirectUri, + false => nativeOAuthRedirectUri, + }; final startUri = apiClient.googleOAuthStartUri(redirectUri); @@ -137,7 +137,6 @@ class AuthRepository { } final tokens = await apiClient.exchangeCode(code: code, clientType: clientType, deviceLabel: deviceLabel); - await _save(tokens); return tokens; } @@ -161,7 +160,6 @@ class AuthRepository { Future updateCurrentUser({String? displayName, String? avatarUrl}) async { final accessToken = await _requireAccessToken(); - return apiClient.updateCurrentUser(accessToken: accessToken, displayName: displayName, avatarUrl: avatarUrl); } diff --git a/app/lib/auth/papyrus_api_config.dart b/app/lib/auth/papyrus_api_config.dart index ca187579..d3417293 100644 --- a/app/lib/auth/papyrus_api_config.dart +++ b/app/lib/auth/papyrus_api_config.dart @@ -10,15 +10,19 @@ class PapyrusApiConfig { : powerSyncServiceUri = powerSyncServiceUri ?? Uri.parse(_defaultPowerSyncServiceUrl); factory PapyrusApiConfig.fromEnvironment() { - const rawBaseUrl = String.fromEnvironment('PAPYRUS_API_BASE_URL', defaultValue: _defaultBaseUrl); - const rawPowerSyncServiceUrl = String.fromEnvironment( + const baseUrl = String.fromEnvironment( + 'PAPYRUS_API_BASE_URL', + defaultValue: _defaultBaseUrl, + ); + + const powerSyncServiceUrl = String.fromEnvironment( 'POWERSYNC_SERVICE_URL', defaultValue: _defaultPowerSyncServiceUrl, ); return PapyrusApiConfig( - serverBaseUri: Uri.parse(rawBaseUrl), - powerSyncServiceUri: Uri.parse(rawPowerSyncServiceUrl), + serverBaseUri: Uri.parse(baseUrl), + powerSyncServiceUri: Uri.parse(powerSyncServiceUrl), ); } @@ -30,7 +34,6 @@ class PapyrusApiConfig { Uri endpoint(String path, [Map? queryParameters]) { final normalizedPath = path.startsWith('/') ? path : '/$path'; final apiPath = '${apiBaseUri.path}$normalizedPath'; - return apiBaseUri.replace(path: apiPath, queryParameters: queryParameters); } } diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index d794fd27..594ebfa3 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -158,6 +158,7 @@ class AppRouter { path: ':shelfId', pageBuilder: (context, state) { final shelfId = state.pathParameters['shelfId']; + return NoTransitionPage( key: state.pageKey, child: ShelfContentsPage(shelfId: shelfId), @@ -191,6 +192,7 @@ class AppRouter { path: 'details/:bookId', pageBuilder: (context, state) { var bookId = state.pathParameters['bookId']; + return NoTransitionPage( key: state.pageKey, child: BookDetailsPage(id: bookId), @@ -203,6 +205,7 @@ class AppRouter { parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) { final bookId = state.pathParameters['bookId']!; + return NoTransitionPage( key: state.pageKey, child: ReaderPage(bookId: bookId), @@ -214,6 +217,7 @@ class AppRouter { path: 'edit/:bookId', pageBuilder: (context, state) { var bookId = state.pathParameters['bookId']; + return NoTransitionPage( key: state.pageKey, child: BookEditPage(id: bookId), @@ -296,6 +300,7 @@ class AppRouter { final acquisitionAvailable = acquisitionAvailabilityProvider.isAvailableFor( syncSettingsProvider.activeApiConfig.serverBaseUri, ); + if (location == '/acquisition' && (!preferencesProvider.acquisitionEnabled || !acquisitionAvailable)) { return '/profile'; } diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index e74a29d4..bbfd7d2b 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -44,9 +44,11 @@ class DataStore extends ChangeNotifier { 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(); @@ -80,45 +82,57 @@ class DataStore extends ChangeNotifier { } void _replaceLibrary(LibrarySnapshot snapshot) { - if (_disposed) return; + if (_disposed) { + return; + } + _shelves ..clear() ..addEntries(snapshot.shelves.map((value) => MapEntry(value.id, value))); + _tags ..clear() ..addEntries(snapshot.tags.map((value) => MapEntry(value.id, value))); + _notes ..clear() ..addEntries(snapshot.notes.map((value) => MapEntry(value.id, value))); + _annotations ..clear() ..addEntries(snapshot.annotations.map((value) => MapEntry(value.id, value))); + _bookmarks ..clear() ..addEntries(snapshot.bookmarks.map((value) => MapEntry(value.id, value))); + _bookShelfRelations ..clear() ..addAll(snapshot.bookShelves); + _bookTagRelations ..clear() ..addAll(snapshot.bookTags); + _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(); + + if (snapshot.books.isEmpty) { + _series.clear(); + } + replaceBooksFromSync(snapshot.books); } - // ============================================================ - // Getters for read access - // ============================================================ - bool get isLoaded => _isLoaded; /// Completes when the active book repository has emitted its first snapshot. @@ -127,12 +141,18 @@ class DataStore extends ChangeNotifier { /// storage is still opening. Callers should wait for this before treating a /// missing ID as authoritative. Future waitUntilLoaded() { - if (_isLoaded) return Future.value(); + if (_isLoaded) { + return Future.value(); + } final completer = Completer(); late VoidCallback listener; + listener = () { - if (!_isLoaded || completer.isCompleted) return; + if (!_isLoaded || completer.isCompleted) { + return; + } + removeListener(listener); completer.complete(); }; @@ -158,24 +178,27 @@ 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 => _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); - // ============================================================ - // Book CRUD - // ============================================================ - Book? getBook(String id) => _books[id]; Future attachBookRepository(BookRepository repository) async { final wasLoaded = _isLoaded; _isLoaded = false; - if (wasLoaded) notifyListeners(); + + if (wasLoaded) { + notifyListeners(); + } + await _bookSubscription?.cancel(); await _librarySubscription?.cancel(); clear(); @@ -192,9 +215,11 @@ class DataStore extends ChangeNotifier { BookRepository requireBookRepository() { final repository = _bookRepository; + if (repository == null) { throw StateError('Book repository is not initialized'); } + return repository is LibraryRepository ? (repository as LibraryRepository).scopedBooks : repository; } @@ -216,6 +241,7 @@ class DataStore extends ChangeNotifier { Future addBookToRepositoryAndWait(BookRepository repository, Book book) async { await repository.upsert(book); + if (isBookRepositoryCurrent(repository) && !identical(_books[book.id], book)) { _books[book.id] = book; notifyListeners(); @@ -247,14 +273,17 @@ class DataStore extends ChangeNotifier { }) 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); + if (target.isCurrent && saved != null) { _books[book.id] = saved; notifyListeners(); @@ -276,10 +305,13 @@ class DataStore extends ChangeNotifier { notifyListeners(); return Future.value(); } + final scope = requireBookRepository(); + return (() async { await repository.upsert(value, previous: previous); final saved = await repository.getById(id); + if (isBookRepositoryCurrent(scope) && saved != null) { apply(saved); notifyListeners(); @@ -293,9 +325,12 @@ class DataStore extends ChangeNotifier { notifyListeners(); return Future.value(); } + final scope = requireBookRepository(); + return (() async { await repository.delete(id); + if (isBookRepositoryCurrent(scope)) { apply(); notifyListeners(); @@ -315,47 +350,55 @@ class DataStore extends ChangeNotifier { Future deleteBookFromRepositoryAndWait(BookRepository repository, String id) async { await repository.delete(id); + if (isBookRepositoryCurrent(repository) && _books.remove(id) != null) { notifyListeners(); } } void replaceBooksFromSync(List books) { - if (_disposed) return; + if (_disposed) { + return; + } + final mergedBooks = books .map((book) { final localBook = _books[book.id]; + if (libraryRepository == null && book.coverMediaId == null && localBook?.coverMediaId != null) { // PowerSync can briefly emit the downloaded server row before its // pending local media-reference update is acknowledged. Keep the // established local reference through that transient null snapshot. return book.copyWith(coverMediaId: localBook!.coverMediaId); } + return book; }) .toList(growable: false); + final syncedIds = mergedBooks.map((book) => book.id).toSet(); + _books ..clear() ..addEntries(mergedBooks.map((book) => MapEntry(book.id, book))); + _bookShelfRelations.removeWhere((relation) => !syncedIds.contains(relation.bookId)); _bookTagRelations.removeWhere((relation) => !syncedIds.contains(relation.bookId)); _annotations.removeWhere((key, annotation) => !syncedIds.contains(annotation.bookId)); _notes.removeWhere((key, note) => !syncedIds.contains(note.bookId)); _bookmarks.removeWhere((key, bookmark) => !syncedIds.contains(bookmark.bookId)); - _isLoaded = true; notifyListeners(); } - // ============================================================ - // Shelf CRUD - // ============================================================ - /// Get a shelf by ID with computed bookCount and coverPreviews. Shelf? getShelf(String id) { final shelf = _shelves[id]; - if (shelf == null) return null; + + if (shelf == null) { + return null; + } + return shelf.copyWith(bookCount: getBookCountForShelf(id), coverPreviews: getCoverPreviewsForShelf(id)); } @@ -378,24 +421,24 @@ class DataStore extends ChangeNotifier { Future deleteShelf(String id, {EntityRepository? repository}) => _deleteEntity(repository ?? libraryRepository?.shelves, id, () { _shelves.remove(id); - _bookShelfRelations.removeWhere((r) => r.shelfId == id); + _bookShelfRelations.removeWhere((book) => book.shelfId == id); }); /// Get all books in a shelf. List getBooksInShelf(String shelfId) { - final bookIds = _bookShelfRelations.where((r) => r.shelfId == shelfId).map((r) => r.bookId); + final bookIds = _bookShelfRelations.where((book) => book.shelfId == shelfId).map((book) => book.bookId); return bookIds.map((id) => _books[id]).whereType().toList(); } /// Get book count for a shelf. int getBookCountForShelf(String shelfId) { - return _bookShelfRelations.where((r) => r.shelfId == shelfId).length; + return _bookShelfRelations.where((book) => book.shelfId == shelfId).length; } /// Get child shelves of a parent shelf, enriched with bookCount/coverPreviews. List getChildShelves(String parentShelfId) { return _shelves.values - .where((s) => s.parentShelfId == parentShelfId) + .where((shelf) => shelf.parentShelfId == parentShelfId) .map( (shelf) => shelf.copyWith( bookCount: getBookCountForShelf(shelf.id), @@ -408,16 +451,13 @@ class DataStore extends ChangeNotifier { /// Get cover previews for a shelf (up to 4 books). List getCoverPreviewsForShelf(String shelfId, {int limit = 4}) { final books = getBooksInShelf(shelfId); + return books .take(limit) - .map((b) => CoverPreview(bookId: b.id, url: b.coverUrl, mediaId: b.coverMediaId, title: b.title)) + .map((book) => CoverPreview(bookId: book.id, url: book.coverUrl, mediaId: book.coverMediaId, title: book.title)) .toList(); } - // ============================================================ - // Tag CRUD - // ============================================================ - Tag? getTag(String id) => _tags[id]; Future addTag(Tag tag, {Tag? previous, EntityRepository? repository}) => @@ -434,24 +474,20 @@ class DataStore extends ChangeNotifier { Future deleteTag(String id, {EntityRepository? repository}) => _deleteEntity(repository ?? libraryRepository?.tags, id, () { _tags.remove(id); - _bookTagRelations.removeWhere((r) => r.tagId == id); + _bookTagRelations.removeWhere((book) => book.tagId == id); }); /// Get all books with a tag. List getBooksWithTag(String tagId) { - final bookIds = _bookTagRelations.where((r) => r.tagId == tagId).map((r) => r.bookId); + final bookIds = _bookTagRelations.where((book) => book.tagId == tagId).map((book) => book.bookId); return bookIds.map((id) => _books[id]).whereType().toList(); } /// Get book count for a tag. int getBookCountForTag(String tagId) { - return _bookTagRelations.where((r) => r.tagId == tagId).length; + return _bookTagRelations.where((book) => book.tagId == tagId).length; } - // ============================================================ - // Series CRUD - // ============================================================ - Series? getSeries(String id) => _series[id]; void addSeries(Series series) { @@ -467,26 +503,24 @@ class DataStore extends ChangeNotifier { void deleteSeries(String id) { _series.remove(id); // Set seriesId to null for books in this series - for (final book in _books.values.where((b) => b.seriesId == id)) { + + for (final book in _books.values.where((book) => book.seriesId == id)) { _books[book.id] = book.copyWith(seriesId: null, seriesNumber: null); } + notifyListeners(); } /// Get all books in a series. List getBooksInSeries(String seriesId) { - return _books.values.where((b) => b.seriesId == seriesId).toList() - ..sort((a, b) => (a.seriesNumber ?? 0).compareTo(b.seriesNumber ?? 0)); + return _books.values.where((book) => book.seriesId == seriesId).toList() + ..sort((left, right) => (left.seriesNumber ?? 0).compareTo(right.seriesNumber ?? 0)); } - // ============================================================ - // Annotation CRUD - // ============================================================ - Annotation? getAnnotation(String id) => _annotations[id]; List getAnnotationsForBook(String bookId) { - return _annotations.values.where((a) => a.bookId == bookId).toList(); + return _annotations.values.where((annotation) => annotation.bookId == bookId).toList(); } Future addAnnotation(Annotation annotation, {Annotation? previous, EntityRepository? repository}) => @@ -515,14 +549,10 @@ class DataStore extends ChangeNotifier { _annotations.remove(id); }); - // ============================================================ - // Note CRUD - // ============================================================ - Note? getNote(String id) => _notes[id]; List getNotesForBook(String bookId) { - return _notes.values.where((n) => n.bookId == bookId).toList(); + return _notes.values.where((note) => note.bookId == bookId).toList(); } Future addNote(Note note, {Note? previous, EntityRepository? repository}) => @@ -541,14 +571,10 @@ class DataStore extends ChangeNotifier { _notes.remove(id); }); - // ============================================================ - // Bookmark CRUD - // ============================================================ - Bookmark? getBookmark(String id) => _bookmarks[id]; List getBookmarksForBook(String bookId) { - return _bookmarks.values.where((b) => b.bookId == bookId).toList(); + return _bookmarks.values.where((book) => book.bookId == bookId).toList(); } Future addBookmark(Bookmark bookmark, {Bookmark? previous, EntityRepository? repository}) => @@ -574,22 +600,18 @@ class DataStore extends ChangeNotifier { _bookmarks.remove(id); }); - // ============================================================ - // Reading Session CRUD - // ============================================================ - ReadingSession? getReadingSession(String id) => _readingSessions[id]; List getReadingSessionsForBook(String bookId) { - return _readingSessions.values.where((s) => s.bookId == bookId).toList(); + return _readingSessions.values.where((item) => item.bookId == bookId).toList(); } List getReadingSessionsInRange(DateTime start, DateTime end) { return _readingSessions.values .where( - (s) => - s.startTime.isAfter(start.subtract(const Duration(seconds: 1))) && - s.startTime.isBefore(end.add(const Duration(seconds: 1))), + (item) => + item.startTime.isAfter(start.subtract(const Duration(seconds: 1))) && + item.startTime.isBefore(end.add(const Duration(seconds: 1))), ) .toList(); } @@ -609,18 +631,16 @@ class DataStore extends ChangeNotifier { notifyListeners(); } - // ============================================================ - // Reading Goal CRUD - // ============================================================ - ReadingGoal? getReadingGoal(String id) => _readingGoals[id]; List get activeGoals { - return readingGoals.where((g) => g.isActive && !g.isArchived && g.endDate.isAfter(DateTime.now().toUtc())).toList(); + return readingGoals + .where((goal) => goal.isActive && !goal.isArchived && goal.endDate.isAfter(DateTime.now().toUtc())) + .toList(); } List get completedGoals { - return readingGoals.where((g) => g.isArchived).toList(); + return readingGoals.where((goal) => goal.isArchived).toList(); } Future addReadingGoal(ReadingGoal goal) => commitTracking(goals: [goal]); @@ -638,6 +658,7 @@ class DataStore extends ChangeNotifier { }) async { final target = repository ?? trackingRepository; final scope = requireBookRepository(); + if (target != null) { await target.commitTracking( goals: goals, @@ -647,22 +668,38 @@ class DataStore extends ChangeNotifier { book: book, previousBook: previousBook, ); - if (!target.isCurrent || !isBookRepositoryCurrent(scope)) return; + + if (!target.isCurrent || !isBookRepositoryCurrent(scope)) { + return; + } } else if (book != null) { await scope.upsert(book); - if (!isBookRepositoryCurrent(scope)) return; + + 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; + + if (deleteGoalId != null) { + _readingGoals.remove(deleteGoalId); + } + + if (book != null) { + _books[book.id] = book; + } + notifyListeners(); } @@ -674,6 +711,7 @@ class DataStore extends ChangeNotifier { String? deviceId, }) { final now = (at ?? DateTime.now()).toUtc(); + if (previous?.readingStatus != LibraryReadingStatus.completed && book.readingStatus == LibraryReadingStatus.completed) { return [ @@ -691,6 +729,7 @@ class DataStore extends ChangeNotifier { ), ]; } + if (previous?.readingStatus == LibraryReadingStatus.completed && book.readingStatus != LibraryReadingStatus.completed) { return effectiveReadingActivities @@ -698,11 +737,13 @@ class DataStore extends ChangeNotifier { .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, @@ -738,23 +779,28 @@ class DataStore extends ChangeNotifier { List _activitySessions() { final groups = >{}; - for (final activity in effectiveReadingActivities.where((a) => a.kind == 'reading')) { + + for (final activity in effectiveReadingActivities.where((activity) => activity.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, @@ -768,10 +814,6 @@ class DataStore extends ChangeNotifier { }).toList(); } - // ============================================================ - // Book-Shelf Relations - // ============================================================ - Future updateBookMemberships({ required Set bookIds, List? shelfIds, @@ -782,6 +824,7 @@ class DataStore extends ChangeNotifier { LibraryMembershipWriter? repository, }) async { final target = repository ?? libraryRepository?.memberships; + if (target != null) { await target.updateMemberships( bookIds: bookIds, @@ -791,8 +834,10 @@ class DataStore extends ChangeNotifier { previousTagIds: previousTagIds, additive: additive, ); + return; } + for (final bookId in bookIds) { if (shelfIds != null) { if (!additive) { @@ -800,16 +845,19 @@ class DataStore extends ChangeNotifier { await removeBookFromShelf(bookId, id); } } + for (final id in shelfIds) { await addBookToShelf(bookId, id); } } + if (tagIds != null) { if (!additive) { for (final id in (previousTagIds ?? getTagIdsForBook(bookId).toSet()).difference(tagIds.toSet())) { await removeTagFromBook(bookId, id); } } + for (final id in tagIds) { await addTagToBook(bookId, id); } @@ -818,11 +866,16 @@ class DataStore extends ChangeNotifier { } Future addBookToShelf(String bookId, String shelfId) { - final exists = _bookShelfRelations.any((r) => r.bookId == bookId && r.shelfId == shelfId); - if (exists) return Future.value(); + final exists = _bookShelfRelations.any((book) => book.bookId == bookId && book.shelfId == shelfId); + + if (exists) { + return Future.value(); + } + final relation = BookShelfRelation(bookId: bookId, shelfId: shelfId, addedAt: DateTime.now().toUtc()); + return _saveEntity(libraryRepository?.bookShelves, relation, '$bookId:$shelfId', null, (saved) { - _bookShelfRelations.removeWhere((r) => r.bookId == bookId && r.shelfId == shelfId); + _bookShelfRelations.removeWhere((book) => book.bookId == bookId && book.shelfId == shelfId); _bookShelfRelations.add(saved); }); } @@ -830,11 +883,11 @@ class DataStore extends ChangeNotifier { Future removeBookFromShelf(String bookId, String shelfId) => _deleteEntity( libraryRepository?.bookShelves, '$bookId:$shelfId', - () => _bookShelfRelations.removeWhere((r) => r.bookId == bookId && r.shelfId == shelfId), + () => _bookShelfRelations.removeWhere((book) => book.bookId == bookId && book.shelfId == shelfId), ); List getShelfIdsForBook(String bookId) { - return _bookShelfRelations.where((r) => r.bookId == bookId).map((r) => r.shelfId).toList(); + return _bookShelfRelations.where((book) => book.bookId == bookId).map((book) => book.shelfId).toList(); } List getShelvesForBook(String bookId) { @@ -842,16 +895,17 @@ class DataStore extends ChangeNotifier { return shelfIds.map((id) => getShelf(id)).whereType().toList(); } - // ============================================================ - // Book-Tag Relations - // ============================================================ - Future addTagToBook(String bookId, String tagId) { - final exists = _bookTagRelations.any((r) => r.bookId == bookId && r.tagId == tagId); - if (exists) return Future.value(); + final exists = _bookTagRelations.any((book) => book.bookId == bookId && book.tagId == tagId); + + if (exists) { + return Future.value(); + } + final relation = BookTagRelation(bookId: bookId, tagId: tagId, createdAt: DateTime.now().toUtc()); + return _saveEntity(libraryRepository?.bookTags, relation, '$bookId:$tagId', null, (saved) { - _bookTagRelations.removeWhere((r) => r.bookId == bookId && r.tagId == tagId); + _bookTagRelations.removeWhere((book) => book.bookId == bookId && book.tagId == tagId); _bookTagRelations.add(saved); }); } @@ -859,11 +913,11 @@ class DataStore extends ChangeNotifier { Future removeTagFromBook(String bookId, String tagId) => _deleteEntity( libraryRepository?.bookTags, '$bookId:$tagId', - () => _bookTagRelations.removeWhere((r) => r.bookId == bookId && r.tagId == tagId), + () => _bookTagRelations.removeWhere((book) => book.bookId == bookId && book.tagId == tagId), ); List getTagIdsForBook(String bookId) { - return _bookTagRelations.where((r) => r.bookId == bookId).map((r) => r.tagId).toList(); + return _bookTagRelations.where((book) => book.bookId == bookId).map((book) => book.tagId).toList(); } List getTagsForBook(String bookId) { @@ -871,10 +925,6 @@ class DataStore extends ChangeNotifier { return tagIds.map((id) => _tags[id]).whereType().toList(); } - // ============================================================ - // Batch loading for initialization - // ============================================================ - void loadData({ List? books, List? shelves, @@ -890,66 +940,87 @@ class DataStore extends ChangeNotifier { }) { if (books != null) { _books.clear(); + for (final book in books) { _books[book.id] = book; } + final repository = _bookRepository; + if (repository is InMemoryBookRepository) { repository.replaceAll(books); } } + if (shelves != null) { _shelves.clear(); + for (final shelf in shelves) { _shelves[shelf.id] = shelf; } } + if (tags != null) { _tags.clear(); + for (final tag in tags) { _tags[tag.id] = tag; } } + if (series != null) { _series.clear(); + for (final s in series) { _series[s.id] = s; } } + if (annotations != null) { _annotations.clear(); + for (final annotation in annotations) { _annotations[annotation.id] = annotation; } } + if (notes != null) { _notes.clear(); + for (final note in notes) { _notes[note.id] = note; } } + if (bookmarks != null) { _bookmarks.clear(); + for (final bookmark in bookmarks) { _bookmarks[bookmark.id] = bookmark; } } + if (readingSessions != null) { _readingSessions.clear(); + for (final session in readingSessions) { _readingSessions[session.id] = session; } } + if (readingGoals != null) { _readingGoals.clear(); + for (final goal in readingGoals) { _readingGoals[goal.id] = goal; } } + if (bookShelfRelations != null) { _bookShelfRelations.clear(); _bookShelfRelations.addAll(bookShelfRelations); } + if (bookTagRelations != null) { _bookTagRelations.clear(); _bookTagRelations.addAll(bookTagRelations); diff --git a/app/lib/data/repositories/tracking_repository.dart b/app/lib/data/repositories/tracking_repository.dart index 840a48ef..cb099954 100644 --- a/app/lib/data/repositories/tracking_repository.dart +++ b/app/lib/data/repositories/tracking_repository.dart @@ -6,6 +6,7 @@ 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 [], diff --git a/app/lib/data/sample_data.dart b/app/lib/data/sample_data.dart index 84bdee25..87e13fb2 100644 --- a/app/lib/data/sample_data.dart +++ b/app/lib/data/sample_data.dart @@ -19,10 +19,6 @@ class SampleData { static DateTime _daysAgo(int days) => DateTime.now().subtract(Duration(days: days)); static DateTime _hoursAgo(int hours) => DateTime.now().subtract(Duration(hours: hours)); - // ============================================================ - // Books (15 books) - // ============================================================ - static List get books { return [ Book( @@ -360,12 +356,9 @@ class SampleData { ]; } - // ============================================================ - // Shelves (8 shelves) - // ============================================================ - static List get shelves { final now = DateTime.now(); + return [ Shelf( id: 'shelf-1', @@ -458,10 +451,6 @@ class SampleData { ]; } - // ============================================================ - // Tags (5 tags / topics) - // ============================================================ - static List get tags { return [ Tag( @@ -502,10 +491,6 @@ class SampleData { ]; } - // ============================================================ - // Series (3 series) - // ============================================================ - static List get seriesList { return [ Series( @@ -541,10 +526,6 @@ class SampleData { ]; } - // ============================================================ - // Book-Shelf Relations - // ============================================================ - static List get bookShelfRelations { return [ // Currently reading (shelf-1) @@ -596,10 +577,6 @@ class SampleData { ]; } - // ============================================================ - // Book-Tag Relations - // ============================================================ - static List get bookTagRelations { return [ // Programming (tag-1) @@ -631,10 +608,6 @@ class SampleData { ]; } - // ============================================================ - // Annotations - // ============================================================ - static List get annotations { return [ // Book 1: The Pragmatic Programmer @@ -710,10 +683,6 @@ class SampleData { ]; } - // ============================================================ - // Notes - // ============================================================ - static List get notes { return [ Note( @@ -764,10 +733,6 @@ class SampleData { ]; } - // ============================================================ - // Bookmarks - // ============================================================ - static List get bookmarks { return [ Bookmark( @@ -799,10 +764,6 @@ class SampleData { ]; } - // ============================================================ - // Reading Sessions (30+ sessions over past month) - // ============================================================ - static List get readingSessions { final sessions = []; var sessionId = 1; @@ -812,12 +773,18 @@ class SampleData { final date = _daysAgo(day); // Skip some days to make it realistic - if (day % 7 == 6) continue; // Skip Sundays - if (day == 5 || day == 12 || day == 19) continue; // Random skips + if (day % 7 == 6) { + continue; + } // Skip Sundays + + if (day == 5 || day == 12 || day == 19) { + continue; + } // Random skips // Morning session (book-1 or book-8) if (day % 2 == 0) { final startTime = DateTime(date.year, date.month, date.day, 7, 30); + sessions.add( ReadingSession( id: 'session-${sessionId++}', @@ -837,6 +804,7 @@ class SampleData { // Evening session (book-3 or book-7) if (day < 20) { final startTime = DateTime(date.year, date.month, date.day, 21, 0); + sessions.add( ReadingSession( id: 'session-${sessionId++}', @@ -857,12 +825,9 @@ class SampleData { return sessions; } - // ============================================================ - // Reading Goals (4 active + 2 completed) - // ============================================================ - static List get readingGoals { final now = DateTime.now(); + return [ // Active goals ReadingGoal( diff --git a/app/lib/goals/goal_calendar.dart b/app/lib/goals/goal_calendar.dart index 58c1b0a9..ca2fb3a7 100644 --- a/app/lib/goals/goal_calendar.dart +++ b/app/lib/goals/goal_calendar.dart @@ -13,8 +13,12 @@ class GoalRange { class GoalCalendar { static String systemTimezone = 'UTC'; static bool _initialized = false; + static void initialize() { - if (_initialized) return; + if (_initialized) { + return; + } + data.initializeTimeZones(); _initialized = true; } @@ -33,6 +37,7 @@ class GoalCalendar { } 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(); @@ -54,6 +59,7 @@ class GoalCalendar { 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), @@ -61,6 +67,7 @@ class GoalCalendar { 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), @@ -68,24 +75,33 @@ class GoalCalendar { 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; + + 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 index f8dc6e88..985449d2 100644 --- a/app/lib/goals/goal_progress.dart +++ b/app/lib/goals/goal_progress.dart @@ -18,6 +18,7 @@ class GoalProgress { this.hasOverlappingTime = false, this.hasCreationCutoff = false, }); + final ReadingGoal goal; final GoalRange range; final int seconds; @@ -30,26 +31,32 @@ class GoalProgress { /// 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); + + double get fraction { + final currentValue = switch (goal.type) { + GoalType.minutes => seconds / 60, + GoalType.pages => pages, + _ => value, + }; + + return (currentValue / goal.targetValue).clamp(0, 1); + } + bool get reached => fraction >= 1; ReadingGoal get projected => goal.copyWith(currentValue: value, estimatedPages: estimated); } @@ -62,11 +69,17 @@ GoalRule ruleAt(ReadingGoal goal, DateTime at) { active: goal.isActive, archived: goal.isArchived, ); - final rules = [...goal.rules]..sort((a, b) => a.at.compareTo(b.at)); + + final rules = [...goal.rules]..sort((left, right) => left.at.compareTo(right.at)); + for (final next in rules) { - if (next.at.isAfter(at)) break; + if (next.at.isAfter(at)) { + break; + } + rule = next; } + return rule; } @@ -78,11 +91,15 @@ bool matchesGoal(ReadingGoal goal, ReadingActivity activity) => switch (goal.sco /// 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)); + if (ranges.isEmpty) { + return 0; + } + + ranges.sort((left, right) => left.$1.compareTo(right.$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); @@ -92,6 +109,7 @@ double unionLength(List<(double, double)> ranges) { end = range.$2; } } + return total + end - start; } @@ -99,6 +117,7 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge 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, @@ -109,6 +128,7 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge ? (!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 = >{}; @@ -120,20 +140,29 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge 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; + ..sort((left, right) { + final order = left.endTime.compareTo(right.endTime); + return order == 0 ? left.id.compareTo(right.id) : order; }); + for (final activity in effective) { - if (!matchesGoal(definition, activity)) continue; + 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) && @@ -143,10 +172,12 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge 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)); @@ -155,17 +186,27 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge } } } + 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; + + if (stop.isAfter(end)) { + stop = end; + } + for (final boundary in boundaries) { - if (boundary.isAfter(cursor) && boundary.isBefore(stop)) stop = boundary; + 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); @@ -173,24 +214,33 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge hasCreationCutoff |= activity.startTime.isBefore(definition.createdAt); contributed = true; } + cursor = stop; } } - if (contributed) counted.add(activity); + + 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)); + + counted.sort((left, right) => right.startTime.compareTo(left.startTime)); + return GoalProgress( goal: goal, range: range, - seconds: dailySeconds.values.fold(0, (a, b) => a + b).floor(), + seconds: dailySeconds.values.fold(0, (total, value) => total + value).floor(), pages: pageCount, finishedBooks: books.length, days: qualified.length, @@ -201,6 +251,6 @@ GoalProgress projectGoal(ReadingGoal definition, Iterable ledge hasCreationCutoff: hasCreationCutoff, hasOverlappingTime: activityIntervals.values.fold(0, (total, ranges) => total + unionLength([...ranges])) > - dailySeconds.values.fold(0, (a, b) => a + b) + .000001, + dailySeconds.values.fold(0, (total, value) => total + value) + .000001, ); } diff --git a/app/lib/goals/reading_activity_calendar.dart b/app/lib/goals/reading_activity_calendar.dart index 15358167..79251ec7 100644 --- a/app/lib/goals/reading_activity_calendar.dart +++ b/app/lib/goals/reading_activity_calendar.dart @@ -6,15 +6,14 @@ 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; + + int get level => switch (!hasActivity) { + true => 0, + false when seconds < 15 * 60 => 1, + false when seconds < 30 * 60 => 2, + false when seconds < 60 * 60 => 3, + false => 4, + }; } /// Calendar presentation uses the same interval union and corrections as goals. @@ -28,30 +27,40 @@ Map readingActivityCalendar( 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; + + 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 e5df180d..fb50a215 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -57,6 +57,7 @@ Future main() async { } catch (_) { GoalCalendar.initialize(); } + final prefs = await SharedPreferences.getInstance(); await ReadingDeviceIdentity.initialize(prefs); runApp(Papyrus(prefs: prefs)); @@ -131,6 +132,7 @@ class AcquisitionDownloadsComposition { final config = _activeApiConfig(); final serverBaseUri = config.serverBaseUri; + final available = _authProvider.isSignedIn && !_authProvider.isOfflineMode && _availabilityProvider.isAvailableFor(serverBaseUri); @@ -196,11 +198,11 @@ class _PapyrusState extends State { @override void initState() { super.initState(); - _officialApiConfig = PapyrusApiConfig.fromEnvironment(); _syncSettingsProvider = SyncSettingsProvider(widget.prefs, officialConfig: _officialApiConfig); _activeProfileKey = _syncSettingsProvider.activeProfileKey; _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); + _profileSwitchQueue = SyncProfileSwitchQueue( initialProfileKey: _activeProfileKey, onError: (error, stackTrace) { @@ -216,17 +218,23 @@ class _PapyrusState extends State { _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _acquisitionAvailabilityProvider = AcquisitionAvailabilityProvider(authProvider: _authProvider); + _acquisitionDownloadsComposition = AcquisitionDownloadsComposition( authProvider: _authProvider, availabilityProvider: _acquisitionAvailabilityProvider, 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; + + if (response.statusCode != 200) { + return 0; + } + return (jsonDecode(response.body) as Map)['tracking_schema_version'] as int? ?? 0; }, connectorFactory: () => PapyrusPowerSyncConnector( @@ -240,16 +248,19 @@ class _PapyrusState extends State { }, ), ); + _bookStorageStatusController = BookStorageStatusController( authProvider: _authProvider, powerSyncService: _powerSyncService, mediaUploadQueue: _mediaUploadQueue, hasBookFile: _bookImportService.hasBookFile, ); + _opdsLibrary = OpdsLibrary(widget.prefs, dataStore: _dataStore); final opdsCache = OpdsResourceCache(widget.prefs); _opdsCatalogs = OpdsCatalogs(OpdsCatalogStore(widget.prefs), library: _opdsLibrary, cache: opdsCache); _opdsHttpClient = OpdsHttpClient(apiConfig: () => _syncSettingsProvider.activeApiConfig, cache: opdsCache); + _opdsDownloads = OpdsDownloads( library: _opdsLibrary, httpClient: _opdsHttpClient, @@ -261,14 +272,17 @@ class _PapyrusState extends State { powerSyncService: _powerSyncService, ), ); + _opdsCatalogs.addListener(_opdsDownloads.reset); unawaited(_dataStore.attachBookRepository(_powerSyncService)); + _appRouter = AppRouter( authProvider: _authProvider, preferencesProvider: _preferencesProvider, syncSettingsProvider: _syncSettingsProvider, acquisitionAvailabilityProvider: _acquisitionAvailabilityProvider, ); + _authProvider.addListener(_syncPowerSyncAuthState); _syncSettingsProvider.addListener(_handleSyncSettingsChanged); _syncPowerSyncAuthState(); @@ -296,6 +310,7 @@ class _PapyrusState extends State { AuthRepository _buildAuthRepository(PapyrusApiConfig config, String profileKey) { final tokenStore = TokenStore(SecureRefreshTokenStorage.scoped(profileKey)); + return AuthRepository( apiClient: AuthApiClient(config: config), tokenStore: tokenStore, @@ -310,6 +325,7 @@ class _PapyrusState extends State { void _syncPowerSyncAuthState() { _updateOpdsScope(); + if (_authStateOperation != null) { _authStateUpdateQueued = true; return; @@ -317,10 +333,12 @@ class _PapyrusState extends State { final operation = _drainAuthStateUpdates(); _authStateOperation = operation; + operation.then( (_) => _clearAuthStateOperation(operation), onError: (Object error, StackTrace stackTrace) { _clearAuthStateOperation(operation); + FlutterError.reportError( FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus media/auth lifecycle'), ); @@ -338,11 +356,13 @@ class _PapyrusState extends State { Future _applyPowerSyncAuthState() async { final user = _authProvider.user; final profileKey = _activeProfileKey; + if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; final scope = MediaStorageScope(profileKey: profileKey, userId: userId); await _mediaUploadQueue.activateScope(scope); await _powerSyncService.activateAuthenticated(userId, profileKey: profileKey); + if (!_switchingSyncProfile && _activeProfileKey == profileKey && _syncSettingsProvider.activeProfileKey == profileKey && @@ -351,6 +371,7 @@ class _PapyrusState extends State { !_authProvider.isOfflineMode) { _opdsCatalogs.setScope(scope.persistenceKey); } + await _refreshMediaUsage(); await _processMediaUploads(); return; @@ -359,16 +380,19 @@ class _PapyrusState extends State { if (_authProvider.isOfflineMode) { await _mediaUploadQueue.activateScope(null); await _powerSyncService.activateGuest(); + if (_authProvider.isOfflineMode && !_switchingSyncProfile && _activeProfileKey == profileKey && _syncSettingsProvider.activeProfileKey == profileKey) { _opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey); } + return; } await _mediaUploadQueue.activateScope(null); + if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { await _powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile); } @@ -378,6 +402,7 @@ class _PapyrusState extends State { if (identical(_authStateOperation, operation)) { _authStateOperation = null; } + if (_authStateUpdateQueued) { _syncPowerSyncAuthState(); } @@ -387,12 +412,17 @@ class _PapyrusState extends State { _acquisitionDownloadsComposition.handleServerChanged(); final nextProfileKey = _syncSettingsProvider.activeProfileKey; final nextConfig = _syncSettingsProvider.activeApiConfig; - if (nextProfileKey != _activeProfileKey) _opdsCatalogs.setScope(null); + + if (nextProfileKey != _activeProfileKey) { + _opdsCatalogs.setScope(null); + } + _profileSwitchQueue.request(nextProfileKey, () => _switchActiveSyncProfile(nextProfileKey, nextConfig)); } Future _switchActiveSyncProfile(String nextProfileKey, PapyrusApiConfig nextConfig) async { _switchingSyncProfile = true; + try { await _mediaUploadQueue.waitUntilIdle(); await _mediaUploadQueue.activateScope(null); @@ -413,21 +443,30 @@ class _PapyrusState extends State { _authProvider.isBootstrapping) { _opdsCatalogs.setScope(null); } else if (_authProvider.isOfflineMode) { - if (_opdsCatalogs.scope != MediaStorageScope.localGuest.persistenceKey) _opdsCatalogs.setScope(null); + if (_opdsCatalogs.scope != MediaStorageScope.localGuest.persistenceKey) { + _opdsCatalogs.setScope(null); + } } else if (_authProvider.isSignedIn && _authProvider.user != null) { final expected = MediaStorageScope( profileKey: _activeProfileKey, userId: _authProvider.user!.userId, ).persistenceKey; - if (_opdsCatalogs.scope != expected) _opdsCatalogs.setScope(null); + + if (_opdsCatalogs.scope != expected) { + _opdsCatalogs.setScope(null); + } } else { _opdsCatalogs.setScope(null); } } Future _refreshMediaUsage() async { - if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) { + return; + } + final repository = _authRepository; + try { await _mediaUploadQueue.refreshUsage(repository.fetchMediaUsage); } catch (_) { @@ -436,18 +475,28 @@ class _PapyrusState extends State { } Future _processMediaUploads() async { - if (_switchingSyncProfile || !_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + if (_switchingSyncProfile || !_authProvider.isSignedIn || _authProvider.isOfflineMode) { + return; + } + final user = _authProvider.user; - if (user == null) return; + + if (user == null) { + return; + } + final profileKey = _activeProfileKey; final repository = _authRepository; final scope = MediaStorageScope(profileKey: profileKey, userId: user.userId); + if (_mediaUploadQueue.activeScope != scope) { await _mediaUploadQueue.activateScope(scope); } + if (_switchingSyncProfile || profileKey != _activeProfileKey || !identical(repository, _authRepository)) { return; } + await _mediaUploadQueue.processPending( dataStore: _dataStore, readBookFile: _bookImportService.getBookFile, @@ -462,6 +511,7 @@ class _PapyrusState extends State { if (error.statusCode == 409) { throw const MediaUploadException.storageFull(); } + rethrow; } }, @@ -473,6 +523,7 @@ class _PapyrusState extends State { }, ), ); + if (identical(repository, _authRepository) && _mediaUploadQueue.activeScope == scope) { await _refreshMediaUsage(); } @@ -508,6 +559,7 @@ class _PapyrusState extends State { child: Consumer( builder: (context, preferencesProvider, child) { final isEink = preferencesProvider.isEinkMode; + return MaterialApp.router( title: 'Papyrus', debugShowCheckedModeBanner: false, diff --git a/app/lib/media/cover_upload_persistence.dart b/app/lib/media/cover_upload_persistence.dart index 22979bb3..bebd654f 100644 --- a/app/lib/media/cover_upload_persistence.dart +++ b/app/lib/media/cover_upload_persistence.dart @@ -20,7 +20,10 @@ Future uploadAndPersistCover({ required CoverPromotionErrorHandler onPromotionError, }) async { final asset = await uploadMedia(payload); - if (payload.kind != MediaKind.coverImage) return asset; + + if (payload.kind != MediaKind.coverImage) { + return asset; + } try { await promotePendingCover(scope, bookId: payload.bookId, mediaId: asset.assetId); @@ -31,5 +34,6 @@ Future uploadAndPersistCover({ // Reporting must not turn an accepted server upload into a retry. } } + return asset; } diff --git a/app/lib/media/local_cover_image_provider.dart b/app/lib/media/local_cover_image_provider.dart index 0c35a4e1..69c3f268 100644 --- a/app/lib/media/local_cover_image_provider.dart +++ b/app/lib/media/local_cover_image_provider.dart @@ -54,6 +54,7 @@ class LocalCoverImageProvider extends ImageProvider { Future _load(ImageDecoderCallback decode) async { final bytes = await loadBytes(); + if (bytes == null || bytes.isEmpty) { throw StateError('Local cover file was not found'); } diff --git a/app/lib/media/media_cache_service.dart b/app/lib/media/media_cache_service.dart index 45d17741..8b950648 100644 --- a/app/lib/media/media_cache_service.dart +++ b/app/lib/media/media_cache_service.dart @@ -18,7 +18,11 @@ class MediaCacheService { /// hash, the bytes match the expected hash. Future getValidCachedBookFile(Book book, {required LocalBookFileReader readLocalBookFile}) async { final cached = await readLocalBookFile(book.id); - if (cached == null) return null; + + if (cached == null) { + return null; + } + return _matchesExpectedHash(cached, book.fileHash) ? cached : null; } @@ -31,14 +35,19 @@ class MediaCacheService { required MediaDownloader downloadMedia, }) async { final cached = await getValidCachedBookFile(book, readLocalBookFile: readLocalBookFile); - if (cached != null) return cached; + + if (cached != null) { + return cached; + } final mediaId = book.fileMediaId; + if (mediaId == null || mediaId.isEmpty) { throw StateError('Book file is not available on this device or server.'); } final downloaded = await downloadMedia(mediaId); + if (!_matchesExpectedHash(downloaded, book.fileHash)) { throw StateError('Downloaded book file did not match the expected hash.'); } @@ -57,7 +66,10 @@ class MediaCacheService { }) { final key = '${scope.persistenceKey}:$mediaId'; final existing = _coverDownloads[key]; - if (existing != null) return existing; + + if (existing != null) { + return existing; + } final operation = _loadAndPersistCover( scope: scope, @@ -66,11 +78,14 @@ class MediaCacheService { writeLocalCover: writeLocalCover, downloadMedia: downloadMedia, ); + _coverDownloads[key] = operation; + operation.then( (_) => _removeCoverOperation(key, operation), onError: (Object error, StackTrace stackTrace) => _removeCoverOperation(key, operation), ); + return operation; } @@ -82,7 +97,10 @@ class MediaCacheService { required MediaDownloader downloadMedia, }) async { final cached = await readLocalCover(scope, mediaId); - if (cached != null) return cached; + + if (cached != null) { + return cached; + } final downloaded = await downloadMedia(mediaId); await writeLocalCover(scope, mediaId, downloaded); @@ -98,7 +116,10 @@ class MediaCacheService { String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString(); bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) { - if (expectedHash == null || expectedHash.isEmpty) return true; + if (expectedHash == null || expectedHash.isEmpty) { + return true; + } + return sha256Hex(bytes) == expectedHash; } diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index d7af9ba5..f108b10d 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -96,7 +96,10 @@ class MediaUploadQueue extends ChangeNotifier { MediaStorageUsage? get storageUsage => _storageUsage; Future activateScope(MediaStorageScope? scope) async { - if (_activeScope == scope) return; + if (_activeScope == scope) { + return; + } + await waitUntilIdle(); await _waitForMutations(); _activeScope = scope; @@ -175,36 +178,45 @@ class MediaUploadQueue extends ChangeNotifier { if (_activeScope != scope) { throw StateError('Authenticated media upload scope changed before import commit'); } + final taskIds = tasks.map((task) => task.id).toSet(); final nextTasks = [..._tasks.where((task) => !taskIds.contains(task.id)), ...tasks]; await _saveTasks(scope, nextTasks); + for (final task in tasks) { _advanceTaskVersion(task.id); } + _tasks = nextTasks; notifyListeners(); }); + await _notifyWorkAvailableBestEffort(); } Future retryFailed({String? bookId}) async { final scope = _requireActiveScope(); var retriedAny = false; + await _withMutation(() async { _tasks = _tasks .map((task) { final matchesBook = bookId == null || task.bookId == bookId; + if (!matchesBook || task.status != MediaUploadTaskStatus.failed) { return task; } + retriedAny = true; _advanceTaskVersion(task.id); return task.copyWith(status: MediaUploadTaskStatus.pending); }) .toList(growable: false); + await _save(scope); notifyListeners(); }); + if (retriedAny) { await onWorkAvailable?.call(); } @@ -212,11 +224,16 @@ class MediaUploadQueue extends ChangeNotifier { Future removeTasksForBook(String bookId) async { final scope = _activeScope; - if (scope == null) return; + + if (scope == null) { + return; + } + await _withMutation(() async { for (final task in _tasks.where((task) => task.bookId == bookId)) { _advanceTaskVersion(task.id); } + _tasks = _tasks.where((task) => task.bookId != bookId).toList(growable: false); await _save(scope); notifyListeners(); @@ -230,20 +247,27 @@ class MediaUploadQueue extends ChangeNotifier { required MediaUploader uploadMedia, }) { final inFlight = _processing; + if (inFlight != null) { _processAgain = true; return inFlight; } + final scope = _activeScope; - if (scope == null) return Future.value(); + + if (scope == null) { + return Future.value(); + } final completer = Completer(); final operation = completer.future; _processing = operation; + unawaited(() async { try { do { _processAgain = false; + await _processPending( scope: scope, dataStore: dataStore, @@ -252,6 +276,7 @@ class MediaUploadQueue extends ChangeNotifier { uploadMedia: uploadMedia, ); } while (_processAgain && _activeScope == scope); + _clearProcessing(operation); completer.complete(); } catch (error, stackTrace) { @@ -259,6 +284,7 @@ class MediaUploadQueue extends ChangeNotifier { completer.completeError(error, stackTrace); } }()); + return operation; } @@ -270,15 +296,23 @@ class MediaUploadQueue extends ChangeNotifier { required MediaUploader uploadMedia, }) async { final processedVersions = {}; + while (_activeScope == scope) { await _waitForMutations(); + final tasks = _tasks .where((task) { - if (task.status == MediaUploadTaskStatus.failed) return false; + if (task.status == MediaUploadTaskStatus.failed) { + return false; + } + return !processedVersions.contains(_taskVersionKey(task.id)); }) .toList(growable: false); - if (tasks.isEmpty) return; + + if (tasks.isEmpty) { + return; + } for (final task in tasks) { final version = _taskVersions[task.id] ?? 0; @@ -286,6 +320,7 @@ class MediaUploadQueue extends ChangeNotifier { try { final bytes = await _bytesForTask(task, scope, readBookFile, readPendingCover); + if (bytes == null) { await _replaceTaskIfCurrent( scope, @@ -293,6 +328,7 @@ class MediaUploadQueue extends ChangeNotifier { version, task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: 'Local file not found'), ); + continue; } @@ -305,6 +341,7 @@ class MediaUploadQueue extends ChangeNotifier { bytes: bytes, ), ); + await _removeTaskIfCurrent(scope, task, version); } on MediaUploadException catch (error) { await _replaceTaskIfCurrent( @@ -330,12 +367,14 @@ class MediaUploadQueue extends ChangeNotifier { Future _enqueue(MediaUploadTask task) async { final scope = _requireActiveScope(); + await _withMutation(() async { _advanceTaskVersion(task.id); _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; await _save(scope); notifyListeners(); }); + await onWorkAvailable?.call(); } @@ -357,7 +396,11 @@ class MediaUploadQueue extends ChangeNotifier { Future _removeTaskIfCurrent(MediaStorageScope scope, MediaUploadTask task, int version) { return _withMutation(() async { final index = _currentTaskIndex(task, version); - if (index < 0) return; + + if (index < 0) { + return; + } + _tasks = [..._tasks]..removeAt(index); await _save(scope); notifyListeners(); @@ -372,7 +415,11 @@ class MediaUploadQueue extends ChangeNotifier { ) { return _withMutation(() async { final index = _currentTaskIndex(task, version); - if (index < 0) return; + + if (index < 0) { + return; + } + _tasks = [..._tasks]..[index] = replacement; await _save(scope); notifyListeners(); @@ -380,7 +427,10 @@ class MediaUploadQueue extends ChangeNotifier { } int _currentTaskIndex(MediaUploadTask task, int version) { - if ((_taskVersions[task.id] ?? 0) != version) return -1; + if ((_taskVersions[task.id] ?? 0) != version) { + return -1; + } + return _tasks.indexWhere((candidate) => identical(candidate, task)); } @@ -394,8 +444,10 @@ class MediaUploadQueue extends ChangeNotifier { final previous = _mutationTail; final released = Completer(); _mutationTail = released.future; + return (() async { await previous; + try { return await mutation(); } finally { @@ -408,7 +460,10 @@ class MediaUploadQueue extends ChangeNotifier { while (true) { final pending = _mutationTail; await pending; - if (identical(pending, _mutationTail)) return; + + if (identical(pending, _mutationTail)) { + return; + } } } @@ -420,15 +475,24 @@ class MediaUploadQueue extends ChangeNotifier { ) async { if (task.kind == MediaKind.coverImage) { final coverBase64 = task.coverBase64; - if (coverBase64 != null) return base64Decode(coverBase64); + + if (coverBase64 != null) { + return base64Decode(coverBase64); + } + return readPendingCover(scope, task.bookId); } + return readBookFile(task.bookId); } List _loadTasks(MediaStorageScope scope) { final raw = _prefs.getString(_storageKey(scope)); - if (raw == null || raw.isEmpty) return []; + + if (raw == null || raw.isEmpty) { + return []; + } + try { final decoded = jsonDecode(raw) as List; return decoded.map((item) => MediaUploadTask.fromJson(item as Map)).toList(growable: false); @@ -441,6 +505,7 @@ class MediaUploadQueue extends ChangeNotifier { context: ErrorDescription('while loading persisted media uploads'), ), ); + return []; } } @@ -449,6 +514,7 @@ class MediaUploadQueue extends ChangeNotifier { Future _saveTasks(MediaStorageScope scope, List tasks) async { final saved = await _prefs.setString(_storageKey(scope), jsonEncode(tasks.map((task) => task.toJson()).toList())); + if (!saved) { throw StateError('Could not persist media upload queue'); } @@ -458,9 +524,11 @@ class MediaUploadQueue extends ChangeNotifier { MediaStorageScope _requireActiveScope() { final scope = _activeScope; + if (scope == null) { throw StateError('Authenticated media upload scope is not active'); } + return scope; } diff --git a/app/lib/models/annotation.dart b/app/lib/models/annotation.dart index 1f8d1855..a8b53630 100644 --- a/app/lib/models/annotation.dart +++ b/app/lib/models/annotation.dart @@ -41,7 +41,6 @@ extension HighlightColorExtension on HighlightColor { } } - /// Get the display name. String get displayName { switch (this) { case HighlightColor.yellow: @@ -74,9 +73,11 @@ class BookLocation { if (chapterTitle != null && chapter != null) { return 'Chapter $chapter: $chapterTitle, Page $pageNumber'; } + if (chapter != null) { return 'Chapter $chapter, Page $pageNumber'; } + return 'Page $pageNumber'; } @@ -85,6 +86,7 @@ class BookLocation { if (chapter != null) { return 'Ch. $chapter, p. $pageNumber'; } + return 'Page $pageNumber'; } @@ -98,7 +100,6 @@ class BookLocation { } } -/// Annotation data model for highlighted text in books. class Annotation { final String id; final String bookId; @@ -149,7 +150,6 @@ class Annotation { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -166,7 +166,6 @@ class Annotation { }; } - /// Create from JSON. factory Annotation.fromJson(Map json) { return Annotation( id: json['id'] as String, diff --git a/app/lib/models/book.dart b/app/lib/models/book.dart index 5050c941..500a9972 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -140,13 +140,19 @@ class Book { /// Get display string for format. String get formatLabel { - if (isPhysical) return 'Physical'; + if (isPhysical) { + return 'Physical'; + } + return fileFormat?.label ?? 'Unknown'; } /// Get all authors as a single string. String get allAuthors { - if (coAuthors.isEmpty) return author; + if (coAuthors.isEmpty) { + return author; + } + return '$author, ${coAuthors.join(', ')}'; } @@ -261,7 +267,6 @@ class Book { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -304,14 +309,13 @@ class Book { }; } - /// Create from JSON. factory Book.fromJson(Map json) { return Book( id: json['id'] as String, title: json['title'] as String, subtitle: json['subtitle'] as String?, author: json['author'] as String, - coAuthors: (json['co_authors'] as List?)?.map((e) => e as String).toList() ?? [], + coAuthors: (json['co_authors'] as List?)?.map((item) => item as String).toList() ?? [], isbn: json['isbn'] as String?, isbn13: json['isbn13'] as String?, publicationDate: json['publication_date'] != null ? DateTime.parse(json['publication_date'] as String) : null, diff --git a/app/lib/models/book_grid_size.dart b/app/lib/models/book_grid_size.dart index c2e4a84a..bf7943ed 100644 --- a/app/lib/models/book_grid_size.dart +++ b/app/lib/models/book_grid_size.dart @@ -7,7 +7,10 @@ abstract final class BookGridSize { static const double defaultWidth = 160; static double normalize(double width) { - if (!width.isFinite) return defaultWidth; + if (!width.isFinite) { + return defaultWidth; + } + return (minimum + ((width - minimum) / step).round() * step).clamp(minimum, maximum); } } diff --git a/app/lib/models/book_shelf_relation.dart b/app/lib/models/book_shelf_relation.dart index 9530b0ce..bbf4fed7 100644 --- a/app/lib/models/book_shelf_relation.dart +++ b/app/lib/models/book_shelf_relation.dart @@ -17,12 +17,10 @@ class BookShelfRelation { ); } - /// Convert to JSON for API/storage. Map toJson() { return {'book_id': bookId, 'shelf_id': shelfId, 'added_at': addedAt.toIso8601String(), 'sort_order': sortOrder}; } - /// Create from JSON. factory BookShelfRelation.fromJson(Map json) { return BookShelfRelation( bookId: json['book_id'] as String, @@ -34,7 +32,10 @@ class BookShelfRelation { @override bool operator ==(Object other) { - if (identical(this, other)) return true; + if (identical(this, other)) { + return true; + } + return other is BookShelfRelation && other.bookId == bookId && other.shelfId == shelfId; } diff --git a/app/lib/models/book_tag_relation.dart b/app/lib/models/book_tag_relation.dart index 7c969096..14a71405 100644 --- a/app/lib/models/book_tag_relation.dart +++ b/app/lib/models/book_tag_relation.dart @@ -15,12 +15,10 @@ class BookTagRelation { ); } - /// Convert to JSON for API/storage. Map toJson() { return {'book_id': bookId, 'tag_id': tagId, 'created_at': createdAt.toIso8601String()}; } - /// Create from JSON. factory BookTagRelation.fromJson(Map json) { return BookTagRelation( bookId: json['book_id'] as String, @@ -31,7 +29,10 @@ class BookTagRelation { @override bool operator ==(Object other) { - if (identical(this, other)) return true; + if (identical(this, other)) { + return true; + } + return other is BookTagRelation && other.bookId == bookId && other.tagId == tagId; } diff --git a/app/lib/models/bookmark.dart b/app/lib/models/bookmark.dart index 1835f559..4103ad27 100644 --- a/app/lib/models/bookmark.dart +++ b/app/lib/models/bookmark.dart @@ -6,7 +6,6 @@ class _Sentinel { const _sentinel = _Sentinel(); -/// Bookmark data model for saved positions within books. class Bookmark { final String id; final String bookId; @@ -28,7 +27,6 @@ class Bookmark { required this.createdAt, }); - /// Get the color from hex string. Color get color { try { final hex = colorHex.replaceFirst('#', ''); @@ -43,9 +41,11 @@ class Bookmark { if (chapterTitle != null && pageNumber != null) { return '$chapterTitle, Page $pageNumber'; } + if (pageNumber != null) { return 'Page $pageNumber'; } + return '${(position * 100).round()}%'; } @@ -79,7 +79,6 @@ class Bookmark { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -93,7 +92,6 @@ class Bookmark { }; } - /// Create from JSON. factory Bookmark.fromJson(Map json) { return Bookmark( id: json['id'] as String, diff --git a/app/lib/models/daily_activity.dart b/app/lib/models/daily_activity.dart index 21a1a3fb..9da88e42 100644 --- a/app/lib/models/daily_activity.dart +++ b/app/lib/models/daily_activity.dart @@ -30,13 +30,21 @@ class DailyActivity { /// Formatted reading time (e.g., "45m", "1h 30m"). String get readingTimeLabel { - if (readingMinutes == 0) return '0m'; - if (readingMinutes < 60) return '${readingMinutes}m'; + if (readingMinutes == 0) { + return '0m'; + } + + if (readingMinutes < 60) { + return '${readingMinutes}m'; + } final hours = readingMinutes ~/ 60; final minutes = readingMinutes % 60; - if (minutes == 0) return '${hours}h'; + if (minutes == 0) { + return '${hours}h'; + } + return '${hours}h ${minutes}m'; } @@ -86,7 +94,6 @@ class DailyActivity { static List get emptyWeek { final now = DateTime.now(); final monday = now.subtract(Duration(days: now.weekday - 1)); - return List.generate(7, (index) => DailyActivity(date: monday.add(Duration(days: index)), readingMinutes: 0)); } } @@ -102,23 +109,37 @@ extension WeeklyActivityStats on List { /// Formatted total reading time. String get totalTimeLabel { final minutes = totalMinutes; - if (minutes == 0) return '0m'; - if (minutes < 60) return '${minutes}m'; + + if (minutes == 0) { + return '0m'; + } + + if (minutes < 60) { + return '${minutes}m'; + } final hours = minutes ~/ 60; final remainingMinutes = minutes % 60; - if (remainingMinutes == 0) return '${hours}h'; + if (remainingMinutes == 0) { + return '${hours}h'; + } + return '${hours}h ${remainingMinutes}m'; } /// Formatted average reading time. String get averageTimeLabel { final avg = averageMinutes; - if (avg == 0) return '0m'; + + if (avg == 0) { + return '0m'; + } + return '${avg}m'; } /// Maximum reading minutes in a single day. - int get maxMinutes => isEmpty ? 0 : map((a) => a.readingMinutes).reduce((a, b) => a > b ? a : b); + int get maxMinutes => + isEmpty ? 0 : map((item) => item.readingMinutes).reduce((total, value) => total > value ? total : value); } diff --git a/app/lib/models/goal_period_record.dart b/app/lib/models/goal_period_record.dart index aa8798f6..bb9cee7c 100644 --- a/app/lib/models/goal_period_record.dart +++ b/app/lib/models/goal_period_record.dart @@ -7,6 +7,7 @@ class GoalPeriodRecord { 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, diff --git a/app/lib/models/library_filter_options.dart b/app/lib/models/library_filter_options.dart index 77f33478..ec1e041f 100644 --- a/app/lib/models/library_filter_options.dart +++ b/app/lib/models/library_filter_options.dart @@ -52,12 +52,14 @@ class LibraryFilterOptions { for (final book in sourceBooks) { sourceBookIds.add(book.id); + for (final author in [book.author, ...book.coAuthors]) { _addNormalized(authors, author); } final language = book.language; final normalizedLanguage = normalizeBookLanguage(language); + if (language != null && normalizedLanguage != null) { languages.putIfAbsent(normalizedLanguage, () => bookLanguageLabel(language)); } @@ -66,8 +68,8 @@ class LibraryFilterOptions { _addNormalized(publishers, book.publisher); _addNormalized(series, book.seriesName); readingStatuses.add(book.readingStatus); - final rating = book.rating; + if (rating == null) { hasUnrated = true; } else { @@ -116,6 +118,7 @@ class LibraryFilterOptions { static void _addNormalized(Map labelsByValue, String? value) { final label = value?.trim(); + if (label == null || label.isEmpty) { return; } @@ -130,6 +133,6 @@ class LibraryFilterOptions { } static List> _sortedOptions(Iterable> options) { - return options.toList()..sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); + return options.toList()..sort((left, right) => left.label.toLowerCase().compareTo(right.label.toLowerCase())); } } diff --git a/app/lib/models/library_filters.dart b/app/lib/models/library_filters.dart index 056ac4b0..7d4eeed6 100644 --- a/app/lib/models/library_filters.dart +++ b/app/lib/models/library_filters.dart @@ -102,20 +102,63 @@ class LibraryFilters { int get activeCategoryCount { var count = 0; - if (authors.isNotEmpty) count++; - if (languages.isNotEmpty) count++; - if (formats.isNotEmpty) count++; - if (topicIds.isNotEmpty) count++; - if (shelfIds.isNotEmpty) count++; - if (publishers.isNotEmpty) count++; - if (seriesNames.isNotEmpty) count++; - if (statuses.isNotEmpty) count++; - if (favoriteFilter != FavoriteFilter.any) count++; - if (progressRange != null) count++; - if (ratings.isNotEmpty || includeUnrated) count++; - if (publicationDateRange != null) count++; - if (dateAddedRange != null) count++; - if (lastReadDateRange != null) count++; + + if (authors.isNotEmpty) { + count++; + } + + if (languages.isNotEmpty) { + count++; + } + + if (formats.isNotEmpty) { + count++; + } + + if (topicIds.isNotEmpty) { + count++; + } + + if (shelfIds.isNotEmpty) { + count++; + } + + if (publishers.isNotEmpty) { + count++; + } + + if (seriesNames.isNotEmpty) { + count++; + } + + if (statuses.isNotEmpty) { + count++; + } + + if (favoriteFilter != FavoriteFilter.any) { + count++; + } + + if (progressRange != null) { + count++; + } + + if (ratings.isNotEmpty || includeUnrated) { + count++; + } + + if (publicationDateRange != null) { + count++; + } + + if (dateAddedRange != null) { + count++; + } + + if (lastReadDateRange != null) { + count++; + } + return count; } diff --git a/app/lib/models/note.dart b/app/lib/models/note.dart index ee722bd5..1c4e057a 100644 --- a/app/lib/models/note.dart +++ b/app/lib/models/note.dart @@ -1,6 +1,5 @@ import 'package:papyrus/models/annotation.dart'; -/// Note data model for user notes about books. class Note { final String id; final String bookId; @@ -26,7 +25,10 @@ class Note { /// Get a preview of the content (first 100 characters). String get preview { - if (content.length <= 100) return content; + if (content.length <= 100) { + return content; + } + return '${content.substring(0, 100)}...'; } @@ -48,6 +50,7 @@ class Note { if (updatedAt != null) { return 'Edited $formattedDate'; } + return 'Created $formattedDate'; } @@ -76,7 +79,6 @@ class Note { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -94,9 +96,9 @@ class Note { }; } - /// Create from JSON. factory Note.fromJson(Map json) { final hasLocation = json['page_number'] != null; + return Note( id: json['id'] as String, bookId: json['book_id'] as String, @@ -110,7 +112,7 @@ class Note { percentage: (json['percentage'] as num?)?.toDouble(), ) : null, - tags: (json['tags'] as List?)?.map((e) => e as String).toList() ?? [], + tags: (json['tags'] as List?)?.map((item) => item as String).toList() ?? [], isPinned: json['is_pinned'] as bool? ?? false, createdAt: DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] != null ? DateTime.parse(json['updated_at'] as String) : null, diff --git a/app/lib/models/reading_activity.dart b/app/lib/models/reading_activity.dart index 89994d03..f01b4d4f 100644 --- a/app/lib/models/reading_activity.dart +++ b/app/lib/models/reading_activity.dart @@ -9,11 +9,13 @@ class PageCoverage { 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, @@ -21,6 +23,7 @@ class PageCoverage { 'pages_per_unit': pagesPerUnit, 'estimated': estimated, }; + factory PageCoverage.fromJson(Map json) => PageCoverage( key: json['key'] as String, start: (json['start'] as num).toDouble(), @@ -51,6 +54,7 @@ class ReadingActivity { this.note, this.correctionOf, }); + final String? sessionId; final int? recordedSeconds; final List constituentIds; @@ -70,6 +74,7 @@ class ReadingActivity { 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, @@ -81,6 +86,7 @@ class ReadingActivity { deviceName: deviceId, createdAt: createdAt, ); + Map toJson() => { 'id': id, 'session_id': sessionId, @@ -98,6 +104,7 @@ class ReadingActivity { 'note': note, 'correction_of': correctionOf, }; + factory ReadingActivity.fromJson(Map json) => ReadingActivity( id: json['id'] as String, sessionId: json['session_id'] as String?, @@ -128,28 +135,41 @@ List effectiveActivities(Iterable activities) /// 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; + entries.sort((left, right) => left.startTime.compareTo(right.startTime)); + + if (entries.length == 1) { + return entries.first; + } + final first = entries.first; - final ends = entries.map((a) => a.endTime).toList()..sort(); + final ends = entries.map((activity) => activity.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; + + if (entry.endTime.isAfter(end)) { + end = entry.endTime; + } } + seconds += end.difference(cursor).inSeconds; + return ReadingActivity( id: first.id, sessionId: first.sessionId, @@ -162,9 +182,10 @@ List groupReadingActivities(Iterable ledger) { deviceId: first.deviceId, shelfIds: first.shelfIds, recordedSeconds: seconds, - coverage: entries.expand((a) => a.coverage).toList(), - constituentIds: entries.map((a) => a.id).toList(), + coverage: entries.expand((activity) => activity.coverage).toList(), + constituentIds: entries.map((activity) => activity.id).toList(), ); - }).toList()..sort((a, b) => b.endTime.compareTo(a.endTime)); + }).toList()..sort((left, right) => right.endTime.compareTo(left.endTime)); + return result; } diff --git a/app/lib/models/reading_goal.dart b/app/lib/models/reading_goal.dart index 36aa1c3d..82c8a964 100644 --- a/app/lib/models/reading_goal.dart +++ b/app/lib/models/reading_goal.dart @@ -1,7 +1,11 @@ String formatDuration(int minutes) { final h = minutes ~/ 60; final m = minutes % 60; - if (h == 0) return '${m}m'; + + if (h == 0) { + return '${m}m'; + } + return m == 0 ? '${h}h' : '${h}h ${m}m'; } @@ -33,6 +37,7 @@ class GoalRule { final String? title; final bool active; final bool archived; + Map toJson() => { 'at': at.toUtc().toIso8601String(), 'target': target, @@ -40,6 +45,7 @@ class GoalRule { 'active': active, 'archived': archived, }; + factory GoalRule.fromJson(Map json) => GoalRule( at: DateTime.parse(json['at'] as String).toUtc(), target: json['target'] as int, @@ -73,6 +79,7 @@ class ReadingGoal { final bool isArchived; final DateTime? completedAt; final bool estimatedPages; + const ReadingGoal({ required this.id, this.title, @@ -97,11 +104,13 @@ class ReadingGoal { this.completedAt, this.estimatedPages = false, }) : createdAt = createdAt ?? startDate; - List get selectedBookIds => scope != GoalScope.book - ? const [] - : bookIds.isNotEmpty - ? bookIds - : [?scopeId]; + + List get selectedBookIds => switch (scope != GoalScope.book) { + true => const [], + false when bookIds.isNotEmpty => bookIds, + false => [?scopeId], + }; + int get target => targetValue; int get current => currentValue; double get progress => targetValue == 0 ? 0 : (currentValue / targetValue).clamp(0.0, 1.0); @@ -109,12 +118,14 @@ class ReadingGoal { bool get isCompleted => currentValue >= targetValue; String get progressLabel => '${(progress * 100).round()}%'; 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', @@ -122,19 +133,25 @@ class ReadingGoal { GoalPeriod.yearly => 'this year', GoalPeriod.custom => 'by ${endDate.day}/${endDate.month}/${endDate.year}', }; + bool get isDaily => period == GoalPeriod.daily; bool get isYearly => period == GoalPeriod.yearly; bool get isCustomPeriod => period == GoalPeriod.custom; + 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, @@ -182,6 +199,7 @@ class ReadingGoal { completedAt: completedAt ?? this.completedAt, estimatedPages: estimatedPages ?? this.estimatedPages, ); + Map toJson() => { 'id': id, 'title': title, @@ -202,6 +220,7 @@ class ReadingGoal { 'is_recurring': isRecurring, 'is_archived': isArchived, }; + factory ReadingGoal.fromJson(Map json) => ReadingGoal( id: json['id'] as String, title: json['title'] as String?, @@ -231,6 +250,7 @@ class ReadingGoal { /// Sample reading goals for backwards compatibility. static List get sampleGoals { final now = DateTime.now(); + return [ ReadingGoal( id: 'goal-1', @@ -288,6 +308,7 @@ class ReadingGoal { /// Sample completed goals for backwards compatibility. static List get sampleCompletedGoals { final now = DateTime.now(); + return [ ReadingGoal( id: 'goal-5', diff --git a/app/lib/models/reading_session.dart b/app/lib/models/reading_session.dart index 5fbb1555..c2009bbd 100644 --- a/app/lib/models/reading_session.dart +++ b/app/lib/models/reading_session.dart @@ -28,28 +28,41 @@ class ReadingSession { /// Duration of the session in minutes. int get durationMinutes { - if (recordedSeconds != null) return recordedSeconds! ~/ 60; - if (endTime == null) return 0; + if (recordedSeconds != null) { + return recordedSeconds! ~/ 60; + } + + if (endTime == null) { + return 0; + } + return endTime!.difference(startTime).inMinutes; } /// Duration as a formatted string. String get durationLabel { final minutes = durationMinutes; + if (minutes < 60) { return '$minutes min'; } + final hours = minutes ~/ 60; final remainingMinutes = minutes % 60; + if (remainingMinutes == 0) { return '$hours hr'; } + return '$hours hr $remainingMinutes min'; } /// Progress made during this session. double get progressMade { - if (endPosition == null) return 0; + if (endPosition == null) { + return 0; + } + return (endPosition! - startPosition).clamp(0.0, 1.0); } @@ -83,7 +96,6 @@ class ReadingSession { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -99,7 +111,6 @@ class ReadingSession { }; } - /// Create from JSON. factory ReadingSession.fromJson(Map json) { return ReadingSession( id: json['id'] as String, diff --git a/app/lib/models/series.dart b/app/lib/models/series.dart index 5cd7466d..d742986b 100644 --- a/app/lib/models/series.dart +++ b/app/lib/models/series.dart @@ -43,7 +43,6 @@ class Series { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -57,7 +56,6 @@ class Series { }; } - /// Create from JSON. factory Series.fromJson(Map json) { return Series( id: json['id'] as String, diff --git a/app/lib/models/shelf.dart b/app/lib/models/shelf.dart index f6d4747c..682d8bba 100644 --- a/app/lib/models/shelf.dart +++ b/app/lib/models/shelf.dart @@ -79,13 +79,18 @@ class Shelf { /// Get display text for book count. String get bookCountLabel { - if (bookCount == 1) return '1 book'; + if (bookCount == 1) { + return '1 book'; + } + return '$bookCount books'; } - /// Get the color from hex string. Color? get color { - if (colorHex == null) return null; + if (colorHex == null) { + return null; + } + try { final hex = colorHex!.replaceFirst('#', ''); return Color(int.parse('FF$hex', radix: 16)); @@ -141,9 +146,9 @@ class Shelf { ); } - /// Convert to JSON for API/storage. Map toJson() { final descriptor = iconDescriptor; + return { 'id': id, 'name': name, @@ -162,9 +167,9 @@ class Shelf { }; } - /// Create from JSON. factory Shelf.fromJson(Map json) { final codePoint = json['icon'] as int?; + final descriptor = codePoint == null ? null : ShelfIconDescriptor( @@ -176,6 +181,7 @@ class Shelf { fontPackage: json['icon_font_package'] as String?, matchTextDirection: json['icon_match_text_direction'] as bool? ?? false, ); + return Shelf( id: json['id'] as String, name: json['name'] as String, @@ -195,12 +201,18 @@ class Shelf { /// Resolve a stored descriptor to a constant display icon. /// Only returns icons from availableIcons to allow tree shaking. static IconData? _iconFromDescriptor(ShelfIconDescriptor? descriptor) { - if (descriptor == null) return null; + if (descriptor == null) { + return null; + } // Look up in available icons only (for tree shaking compatibility) + for (final icon in availableIcons) { - if (descriptor.matches(icon)) return icon; + if (descriptor.matches(icon)) { + return icon; + } } // Return default icon if not found (instead of creating non-const IconData) + return Icons.folder_outlined; } @@ -251,6 +263,7 @@ class Shelf { /// Sample shelves for backwards compatibility. static List get sampleShelves { final now = DateTime.now(); + return [ Shelf( id: 'shelf-1', diff --git a/app/lib/models/tag.dart b/app/lib/models/tag.dart index 074524c8..0708fbfe 100644 --- a/app/lib/models/tag.dart +++ b/app/lib/models/tag.dart @@ -10,7 +10,6 @@ class Tag { const Tag({required this.id, required this.name, required this.colorHex, this.description, required this.createdAt}); - /// Get the color from hex string. Color get color { try { final hex = colorHex.replaceFirst('#', ''); @@ -38,7 +37,6 @@ class Tag { ); } - /// Convert to JSON for API/storage. Map toJson() { return { 'id': id, @@ -49,7 +47,6 @@ class Tag { }; } - /// Create from JSON. factory Tag.fromJson(Map json) { return Tag( id: json['id'] as String, diff --git a/app/lib/opds/opds_browser.dart b/app/lib/opds/opds_browser.dart index 3887d985..baafb362 100644 --- a/app/lib/opds/opds_browser.dart +++ b/app/lib/opds/opds_browser.dart @@ -11,6 +11,7 @@ class OpdsBrowser extends ChangeNotifier { OpdsBrowser({OpdsHttpClient? httpClient}) : httpClient = httpClient ?? OpdsHttpClient() { this.httpClient.cache?.addListener(_cacheChanged); } + final OpdsHttpClient httpClient; OpdsFeed? feed; String? error; @@ -28,9 +29,17 @@ class OpdsBrowser extends ChangeNotifier { void _cacheChanged() { final token = _activeCacheToken; - if (_disposed || token == null || httpClient.cache!.isCurrent(token)) return; + + if (_disposed || token == null || httpClient.cache!.isCurrent(token)) { + return; + } + _activeCacheToken = null; - if (!_invalidatingAuthorization) _cancellation.cancel(); + + if (!_invalidatingAuthorization) { + _cancellation.cancel(); + } + feed = null; isCached = false; fetchedAt = null; @@ -45,6 +54,7 @@ class OpdsBrowser extends ChangeNotifier { void _checkRequest(OpdsCancellation token, OpdsCacheToken? cacheToken) { token.check(); + if (_disposed || (cacheToken != null && !httpClient.cache!.isCurrent(cacheToken))) { throw const OpdsCancelled(); } @@ -62,8 +72,10 @@ class OpdsBrowser extends ChangeNotifier { final cache = httpClient.cache; cacheToken ??= cache?.capture(catalog, uri); _checkRequest(token, cacheToken); + if (preferCached && cacheToken != null) { final cached = cache!.read(cacheToken); + if (cached != null) { try { return parse(cached.response); @@ -72,27 +84,36 @@ class OpdsBrowser extends ChangeNotifier { } } } + try { final response = await httpClient.get(catalog, uri, credentials: credentials, cancellation: token); _checkRequest(token, cacheToken); final parsed = parse(response); - if (cacheToken != null) await cache!.write(cacheToken, response); + + if (cacheToken != null) { + await cache!.write(cacheToken, response); + } + _checkRequest(token, cacheToken); return parsed; } on OpdsAuthorizationException { _checkRequest(token, cacheToken); + if (cacheToken != null) { // Other browsers cancel immediately; this request still reports its // authorization error after the synchronous invalidation notification. late Future invalidating; _invalidatingAuthorization = true; + try { invalidating = cache!.invalidateCatalog(catalog); } finally { _invalidatingAuthorization = false; } + await invalidating; } + rethrow; } } @@ -111,8 +132,10 @@ class OpdsBrowser extends ChangeNotifier { final cache = httpClient.cache; final cacheToken = cache?.capture(catalog, uri); _activeCacheToken = cacheToken; + if (cacheToken != null) { final cached = cache!.read(cacheToken); + if (cached != null) { try { feed = _parse(cached.response); @@ -123,24 +146,34 @@ class OpdsBrowser extends ChangeNotifier { } } } + loading = true; _notify(); + try { final loaded = await _fetch(catalog, uri, token, credentials, _parse, cacheToken: cacheToken); - if (token.isCancelled || _disposed) return; + + if (token.isCancelled || _disposed) { + return; + } + feed = loaded; isCached = false; fetchedAt = cacheToken == null ? DateTime.now() : cache!.read(cacheToken)?.fetchedAt ?? DateTime.now(); } on OpdsCancelled { return; } catch (failure) { - if (token.isCancelled || _disposed) return; + if (token.isCancelled || _disposed) { + return; + } + if (failure is OpdsAuthorizationException) { feed = null; isCached = false; fetchedAt = null; authorizationFailed = true; } + error = opdsErrorMessage(failure); } finally { if (!token.isCancelled && !_disposed) { @@ -149,6 +182,7 @@ class OpdsBrowser extends ChangeNotifier { isCached = false; fetchedAt = null; } + loading = false; _notify(); } @@ -157,14 +191,23 @@ class OpdsBrowser extends ChangeNotifier { Future search(String query) async { final catalog = _catalog; - if (catalog == null || query.trim().isEmpty) throw const OpdsException('Enter a search term.'); + + if (catalog == null || query.trim().isEmpty) { + throw const OpdsException('Enter a search term.'); + } + final token = _cancellation; final cacheToken = httpClient.cache?.capture(catalog, catalog.uri); + try { var link = feed?.searchLink; link ??= (await _fetch(catalog, catalog.uri, token, _credentials, _parse, preferCached: true)).searchLink; _checkRequest(token, cacheToken); - if (link == null) throw const OpdsException('This catalog does not advertise keyword search.'); + + if (link == null) { + throw const OpdsException('This catalog does not advertise keyword search.'); + } + if (link.type?.split(';').first.trim().toLowerCase() == 'application/opensearchdescription+xml') { link = await _fetch( catalog, @@ -175,6 +218,7 @@ class OpdsBrowser extends ChangeNotifier { preferCached: true, ); } + _checkRequest(token, cacheToken); return OpdsHttpClient.validateUri(Uri.parse(OpdsSearch.expand(link.template, query.trim()))); } on OpdsAuthorizationException catch (failure) { @@ -186,6 +230,7 @@ class OpdsBrowser extends ChangeNotifier { error = failure.message; _notify(); } + rethrow; } } @@ -205,7 +250,9 @@ class OpdsBrowser extends ChangeNotifier { } void _notify() { - if (!_disposed) notifyListeners(); + if (!_disposed) { + notifyListeners(); + } } @override @@ -218,7 +265,13 @@ class OpdsBrowser extends ChangeNotifier { } String opdsErrorMessage(Object error) { - if (error is OpdsException) return error.message; - if (error is FormatException) return 'This response is not a supported OPDS catalog. Check the catalog URL.'; + if (error is OpdsException) { + return error.message; + } + + if (error is FormatException) { + return 'This response is not a supported OPDS catalog. Check the catalog URL.'; + } + return 'Could not load this catalog. Check its settings and retry.'; } diff --git a/app/lib/opds/opds_catalog_store.dart b/app/lib/opds/opds_catalog_store.dart index 7303781f..8d2b8be4 100644 --- a/app/lib/opds/opds_catalog_store.dart +++ b/app/lib/opds/opds_catalog_store.dart @@ -14,10 +14,13 @@ abstract interface class OpdsSecretStorage { class SecureOpdsSecretStorage implements OpdsSecretStorage { const SecureOpdsSecretStorage(); static const _storage = FlutterSecureStorage(); + @override Future read(String key) => _storage.read(key: key); + @override Future write(String key, String value) => _storage.write(key: key, value: value); + @override Future delete(String key) => _storage.delete(key: key); } @@ -32,7 +35,11 @@ class OpdsCatalogStore { List load(String scope) { final raw = _prefs.getString(_key(scope)); - if (raw == null) return []; + + if (raw == null) { + return []; + } + try { return (jsonDecode(raw) as List) .map((item) => OpdsCatalog.fromJson(Map.from(item as Map))) @@ -44,15 +51,21 @@ class OpdsCatalogStore { Future credentials(String scope, String id, {String? expectedOrigin}) async { final raw = await _secrets.read(_secretKey(scope, id)); - if (raw == null) return null; + + if (raw == null) { + return null; + } + final data = jsonDecode(raw) as Map; final catalogs = load(scope); final index = catalogs.indexWhere((entry) => entry.id == id); + if (index < 0 || data['origin'] != catalogs[index].uri.origin || (expectedOrigin != null && data['origin'] != expectedOrigin)) { return null; } + return OpdsCredentials(username: data['username'] as String, password: data['password'] as String); } @@ -66,7 +79,11 @@ class OpdsCatalogStore { Future save(String scope, OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) => _serialize(() async { OpdsHttpClient.validateUri(catalog.uri); - if (catalog.name.trim().isEmpty || catalog.id.isEmpty) throw const OpdsException('Enter a catalog name.'); + + if (catalog.name.trim().isEmpty || catalog.id.isEmpty) { + throw const OpdsException('Enter a catalog name.'); + } + final catalogKey = _key(scope); final oldCatalogs = _prefs.getString(catalogKey); final catalogs = load(scope); @@ -74,6 +91,7 @@ class OpdsCatalogStore { final originChanged = index >= 0 && catalogs[index].uri.origin != catalog.uri.origin; final key = _secretKey(scope, catalog.id); final oldSecret = await _secrets.read(key); + try { if (credentials != null) { await _secrets.write( @@ -87,12 +105,14 @@ class OpdsCatalogStore { } else if (clearCredentials || originChanged) { await _secrets.delete(key); } + if (index < 0) { catalogs.add(catalog); } else { catalogs[index] = catalog; } - if (!await _prefs.setString(catalogKey, jsonEncode(catalogs.map((e) => e.toJson()).toList()))) { + + if (!await _prefs.setString(catalogKey, jsonEncode(catalogs.map((item) => item.toJson()).toList()))) { throw const OpdsException('Could not save this catalog.'); } } catch (_) { @@ -107,9 +127,11 @@ class OpdsCatalogStore { final catalogs = load(scope)..removeWhere((entry) => entry.id == id); final key = _secretKey(scope, id); final oldSecret = await _secrets.read(key); + try { await _secrets.delete(key); - if (!await _prefs.setString(catalogKey, jsonEncode(catalogs.map((e) => e.toJson()).toList()))) { + + if (!await _prefs.setString(catalogKey, jsonEncode(catalogs.map((item) => item.toJson()).toList()))) { throw const OpdsException('Could not remove this catalog.'); } } catch (_) { @@ -130,6 +152,7 @@ class OpdsCatalogStore { } catch (_) { // Still attempt secret restoration and preserve the original failure. } + try { if (oldSecret == null) { await _secrets.delete(secretKey); diff --git a/app/lib/opds/opds_catalogs.dart b/app/lib/opds/opds_catalogs.dart index 229f8fd1..08490776 100644 --- a/app/lib/opds/opds_catalogs.dart +++ b/app/lib/opds/opds_catalogs.dart @@ -17,15 +17,22 @@ class OpdsCatalogs extends ChangeNotifier { List catalogs = const []; String? error; bool _disposed = false; + OpdsCatalog? find(String id) { for (final catalog in catalogs) { - if (catalog.id == id) return catalog; + if (catalog.id == id) { + return catalog; + } } + return null; } void setScope(String? scope) { - if (_scope == scope) return; + if (_scope == scope) { + return; + } + _scope = scope; library?.setScope(scope); cache?.setScope(scope); @@ -35,48 +42,80 @@ class OpdsCatalogs extends ChangeNotifier { void reload() { revision++; error = null; + try { catalogs = _scope == null ? const [] : List.unmodifiable(store.load(_scope!)); } catch (failure) { catalogs = const []; error = opdsErrorMessage(failure); } - if (!_disposed) notifyListeners(); + + if (!_disposed) { + notifyListeners(); + } } Future credentials(String id) async { final scope = _scope; - if (scope == null) throw const OpdsException('Wait for the library account to finish loading.'); + + if (scope == null) { + throw const OpdsException('Wait for the library account to finish loading.'); + } + final catalog = find(id); final currentRevision = revision; - if (catalog == null) throw const OpdsCancelled(); + + if (catalog == null) { + throw const OpdsCancelled(); + } + final credentials = await store.credentials(scope, id, expectedOrigin: catalog.uri.origin); - if (_scope != scope || revision != currentRevision || _disposed) throw const OpdsCancelled(); + + if (_scope != scope || revision != currentRevision || _disposed) { + throw const OpdsCancelled(); + } + return credentials; } Future save(OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) async { final scope = _scope; - if (scope == null) throw const OpdsException('Wait for the library account to finish loading.'); + + if (scope == null) { + throw const OpdsException('Wait for the library account to finish loading.'); + } + final previous = find(catalog.id); await store.save(scope, catalog, credentials: credentials, clearCredentials: clearCredentials); + if (previous != null && (previous.uri != catalog.uri || credentials != null || clearCredentials)) { await cache?.invalidateCatalog(previous, scope: scope); await library?.forgetCatalog(previous.id, scope: scope); } - if (_scope == scope && !_disposed) reload(); + + if (_scope == scope && !_disposed) { + reload(); + } } Future remove(String id) async { final scope = _scope; - if (scope == null) return; + + if (scope == null) { + return; + } + final catalog = find(id); await store.remove(scope, id); + if (catalog != null) { await cache?.invalidateCatalog(catalog, scope: scope); await library?.forgetCatalog(id, scope: scope); } - if (_scope == scope && !_disposed) reload(); + + if (_scope == scope && !_disposed) { + reload(); + } } @override diff --git a/app/lib/opds/opds_downloads.dart b/app/lib/opds/opds_downloads.dart index fc60b70a..ad608629 100644 --- a/app/lib/opds/opds_downloads.dart +++ b/app/lib/opds/opds_downloads.dart @@ -33,6 +33,7 @@ class OpdsDownloads extends ChangeNotifier { : httpClient = httpClient ?? OpdsHttpClient() { library?.addListener(_notify); } + final OpdsLibrary? library; final BookImportSession Function() captureImport; final OpdsHttpClient httpClient; @@ -46,27 +47,41 @@ class OpdsDownloads extends ChangeNotifier { static bool supports(OpdsLink link) => (kIsWeb ? bookImportWebExtensions : bookImportNativeExtensions).contains(link.supportedExtension); + static String jobKey(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link) => '${catalog.id}\n${publication.id}\n${link.uri}'; Future start(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link, {OpdsCredentials? credentials}) { final key = jobKey(catalog, publication, link); - if (_operations.containsKey(key)) return _operations[key]!; - if (_disposed) return Future.value(); + + if (_operations.containsKey(key)) { + return _operations[key]!; + } + + if (_disposed) { + return Future.value(); + } + final job = OpdsDownloadJob(key: key, catalog: catalog, publication: publication, link: link); _jobs[key] = job; final existing = libraryBookId(catalog, publication, link: link); + if (existing != null) { job.bookId = existing; job.status = OpdsDownloadStatus.complete; _notify(); return Future.value(); } + final operation = _run(job, credentials); _operations[key] = operation; + operation.whenComplete(() { - if (identical(_operations[key], operation)) _operations.remove(key); + if (identical(_operations[key], operation)) { + _operations.remove(key); + } }); + return operation; } @@ -75,8 +90,10 @@ class OpdsDownloads extends ChangeNotifier { BookImportSession? session; BookImportResult? imported; var committed = false; + void check() { job.cancellation.check(); + if (_disposed || !identical(_jobs[job.key], job) || (session != null && !session.isCurrent())) { throw const OpdsCancelled(); } @@ -86,9 +103,11 @@ class OpdsDownloads extends ChangeNotifier { if (!supports(job.link)) { throw const OpdsException('This format or acquisition method is not supported on this device.'); } + session = captureImport(); check(); _notify(); + final response = await httpClient.get( job.catalog, job.link.uri, @@ -101,22 +120,27 @@ class OpdsDownloads extends ChangeNotifier { _notify(); }, ); + check(); final contentType = response.headers['content-type']?.split(';').first.trim().toLowerCase(); + final prefix = utf8 .decode(response.bytes.take(1024).toList(), allowMalformed: true) .replaceFirst('\uFEFF', '') .trimLeft(); + final isHtml = RegExp( r'^(?:<\?xml[^>]*>\s*)?(?: value == null || value.trim().isEmpty || ['unknown', 'unknown author'].contains(value.trim().toLowerCase()); + final missingAuthor = missing(result.author); + return BookImportResult( bookId: result.bookId, title: missing(result.title) || result.title == 'book' ? publication.title : result.title, diff --git a/app/lib/opds/opds_http_client.dart b/app/lib/opds/opds_http_client.dart index 3c04eb4d..ba93bfeb 100644 --- a/app/lib/opds/opds_http_client.dart +++ b/app/lib/opds/opds_http_client.dart @@ -11,6 +11,7 @@ import 'package:papyrus/opds/opds_resource_cache.dart'; class OpdsException implements Exception { const OpdsException(this.message); final String message; + @override String toString() => message; } @@ -39,16 +40,24 @@ class OpdsCancellation { bool _cancelled = false; final Set _listeners = {}; bool get isCancelled => _cancelled; + void check() { - if (_cancelled) throw const OpdsCancelled(); + if (_cancelled) { + throw const OpdsCancelled(); + } } void cancel() { - if (_cancelled) return; + if (_cancelled) { + return; + } + _cancelled = true; + for (final listener in _listeners.toList()) { listener(); } + _listeners.clear(); } @@ -76,6 +85,7 @@ class OpdsHttpClient { OpdsHttpClient({http.Client Function()? clientFactory, PapyrusApiConfig Function()? apiConfig, this.cache}) : _clientFactory = clientFactory ?? http.Client.new, _apiConfig = apiConfig ?? PapyrusApiConfig.fromEnvironment; + final http.Client Function() _clientFactory; final PapyrusApiConfig Function() _apiConfig; final OpdsResourceCache? cache; @@ -84,17 +94,23 @@ class OpdsHttpClient { Future _acquire(OpdsCancellation? cancellation) async { cancellation?.check(); + if (_activeRequests < 4) { _activeRequests++; return; } + final pending = Completer(); _waitingRequests.add(pending); + void cancel() { - if (_waitingRequests.remove(pending)) pending.completeError(const OpdsCancelled()); + if (_waitingRequests.remove(pending)) { + pending.completeError(const OpdsCancelled()); + } } cancellation?.addListener(cancel); + try { await pending.future; } finally { @@ -114,6 +130,7 @@ class OpdsHttpClient { if (!['http', 'https'].contains(uri.scheme) || uri.host.isEmpty || uri.userInfo.isNotEmpty) { throw const OpdsException('Enter an HTTP or HTTPS URL without embedded credentials.'); } + return uri; } @@ -131,46 +148,76 @@ class OpdsHttpClient { final relayUri = validateUri(_apiConfig().endpoint('/opds/relay')); await _acquire(cancellation); http.Client? client; + try { cancellation?.check(); client = _clientFactory(); cancellation?.addListener(client.close); late http.StreamedResponse response; + final body = jsonEncode({ 'url': uri.toString(), 'catalog_url': catalog.uri.toString(), 'max_bytes': maxBytes, if (credentials != null) 'credentials': {'username': credentials.username, 'password': credentials.password}, }); + for (var attempt = 0; ; attempt++) { cancellation?.check(); + final request = http.Request('POST', relayUri) ..headers['content-type'] = 'application/json' ..body = body; + response = await client.send(request).timeout(const Duration(seconds: 35)); - if (response.statusCode == 200) break; + + if (response.statusCode == 200) { + break; + } + final error = await _relayError(response); - if (error is! _OpdsRelayBusy || attempt >= 2) throw error; + + if (error is! _OpdsRelayBusy || attempt >= 2) { + throw error; + } + await Future.delayed(Duration(milliseconds: 500 * (attempt + 1))); } + final upstreamUrl = response.headers['x-opds-url']; + if (upstreamUrl == null) { throw const OpdsException( 'The Papyrus server returned an invalid catalog response. Check its relay configuration.', ); } + final finalUri = validateUri(Uri.parse(upstreamUrl)); final total = response.contentLength; - if (total != null && total > maxBytes) throw const OpdsException('This resource is too large to load.'); + + if (total != null && total > maxBytes) { + throw const OpdsException('This resource is too large to load.'); + } + final bytes = BytesBuilder(copy: false); + await for (final chunk in response.stream.timeout(const Duration(seconds: 35))) { cancellation?.check(); - if (bytes.length + chunk.length > maxBytes) throw const OpdsException('This resource is too large to load.'); + + if (bytes.length + chunk.length > maxBytes) { + throw const OpdsException('This resource is too large to load.'); + } + bytes.add(chunk); onProgress?.call(bytes.length, total); } + cancellation?.check(); - if (total != null && bytes.length != total) throw const OpdsConnectionException(); + + if (total != null && bytes.length != total) { + throw const OpdsConnectionException(); + } + return OpdsResponse(uri: finalUri, bytes: bytes.takeBytes(), headers: response.headers); } on OpdsException { rethrow; @@ -182,24 +229,33 @@ class OpdsHttpClient { cancellation?.removeListener(client.close); client.close(); } + _release(); } } Future _relayError(http.StreamedResponse response) async { final bytes = BytesBuilder(copy: false); + await for (final chunk in response.stream.timeout(const Duration(seconds: 35))) { - if (bytes.length + chunk.length > 8192) break; + if (bytes.length + chunk.length > 8192) { + break; + } + bytes.add(chunk); } + try { final body = jsonDecode(utf8.decode(bytes.takeBytes())); + if (body is Map && body['error'] is Map) { final error = body['error'] as Map; + if (error['code'] == 'OPDS_RELAY_ERROR' && error['message'] is String) { if (response.statusCode == 503 && error['details'] is Map && error['details']['retryable'] == true) { return _OpdsRelayBusy(error['message'] as String); } + return response.statusCode == 401 || response.statusCode == 403 ? OpdsAuthorizationException(error['message'] as String) : OpdsException(error['message'] as String); @@ -208,6 +264,7 @@ class OpdsHttpClient { } on FormatException { // Gateways can return HTML instead of the API error envelope. } + final message = switch (response.statusCode) { 401 => 'Check the catalog credentials and the Papyrus server relay configuration.', 403 => 'The catalog or Papyrus server denied access. Check the catalog settings.', @@ -216,6 +273,7 @@ class OpdsHttpClient { 429 => 'Too many catalog requests. Please wait and retry.', _ => 'The catalog relay returned HTTP ${response.statusCode}. Please retry later.', }; + return response.statusCode == 401 || response.statusCode == 403 ? OpdsAuthorizationException(message) : OpdsException(message); diff --git a/app/lib/opds/opds_json_parser.dart b/app/lib/opds/opds_json_parser.dart index 58fbe1fe..63dc5918 100644 --- a/app/lib/opds/opds_json_parser.dart +++ b/app/lib/opds/opds_json_parser.dart @@ -5,23 +5,30 @@ import 'opds_models.dart'; class OpdsJsonParser { static OpdsFeed parse(String body, Uri uri, {String? contentType}) { final decoded = jsonDecode(body); + if (decoded is! Map || decoded['metadata'] is! Map) { throw const FormatException('The response is not an OPDS JSON document.'); } + final metadata = _object(decoded['metadata']); final title = _text(metadata['title']); + if (title == null) { throw const FormatException('The OPDS document has no title.'); } + final links = _links(decoded['links'], uri); final hasFeedContent = ['publications', 'navigation', 'groups'].any(decoded.containsKey); + final isPublication = !hasFeedContent && ((contentType ?? '').toLowerCase().startsWith('application/opds-publication+json') || links.any((link) => link.isAcquisition)); + if (!hasFeedContent && !isPublication) { throw const FormatException('The JSON document contains no OPDS collections.'); } + return OpdsFeed( uri: uri, title: title, @@ -47,7 +54,11 @@ class OpdsJsonParser { final links = _links(json['links'], uri); final identifier = _text(metadata['identifier']); final title = _text(metadata['title']); - if (title == null) throw const FormatException('An OPDS publication has no metadata title.'); + + if (title == null) { + throw const FormatException('An OPDS publication has no metadata title.'); + } + return OpdsPublication( id: identifier ?? (links.isNotEmpty ? links.first.uri.toString() : title), title: title, @@ -68,14 +79,20 @@ class OpdsJsonParser { static List _links(dynamic value, Uri uri) { final result = []; + for (final json in _objects(value)) { final href = json['href']; - if (href is! String || href.trim().isEmpty) continue; + + if (href is! String || href.trim().isEmpty) { + continue; + } + final properties = _object(json['properties']); final indirect = properties['indirectAcquisition']; final images = json['images']; final image = images is List && images.isNotEmpty ? images.first : json['image']; final imageHref = image is String ? image : _object(image)['href']; + result.add( OpdsLink( uri: uri.resolve(href), @@ -91,19 +108,25 @@ class OpdsJsonParser { ), ); } + return result; } static Map _object(dynamic value) => value is Map ? value : const {}; static Iterable> _objects(dynamic value) { - if (value == null) return const []; + if (value == null) { + return const []; + } + if (value is! List) { throw const FormatException('An OPDS collection must be an array.'); } + if (value.any((item) => item is! Map)) { throw const FormatException('An OPDS collection contains an invalid member.'); } + return value.cast>(); } @@ -112,13 +135,21 @@ class OpdsJsonParser { final text = opdsPlainText(value); return text.isEmpty ? null : text; } + if (value is Map) { - if (value.containsKey('name')) return _text(value['name']); + if (value.containsKey('name')) { + return _text(value['name']); + } + for (final localized in value.values) { final text = _text(localized); - if (text != null) return text; + + if (text != null) { + return text; + } } } + return null; } diff --git a/app/lib/opds/opds_library.dart b/app/lib/opds/opds_library.dart index 4bbfa85e..62140145 100644 --- a/app/lib/opds/opds_library.dart +++ b/app/lib/opds/opds_library.dart @@ -32,7 +32,10 @@ class OpdsLibrary extends ChangeNotifier { String _key(String scope) => 'papyrus.opds.imports.v1.${Uri.encodeComponent(scope)}'; void setScope(String? scope) { - if (_scope == scope) return; + if (_scope == scope) { + return; + } + _scope = scope; _generation++; _imports = scope == null ? {} : _scopedImports.putIfAbsent(scope, () => _read(scope)); @@ -41,16 +44,29 @@ class OpdsLibrary extends ChangeNotifier { Map> _read(String scope) { final imports = >{}; + try { final raw = _prefs.getString(_key(scope)); - if (raw == null) return imports; + + if (raw == null) { + return imports; + } + final decoded = jsonDecode(raw) as Map; + for (final entry in decoded.entries) { try { final parts = jsonDecode(entry.key); - if (parts is! List || parts.length != 4 || parts.any((part) => part is! String)) continue; + + if (parts is! List || parts.length != 4 || parts.any((part) => part is! String)) { + continue; + } + final value = Map.from(entry.value as Map); - if (value['book'] is String && value['sample'] is bool) imports[entry.key] = value; + + if (value['book'] is String && value['sample'] is bool) { + imports[entry.key] = value; + } } catch (_) { // Ignore a damaged record without losing unrelated import identities. } @@ -58,12 +74,17 @@ class OpdsLibrary extends ChangeNotifier { } catch (_) { // Broken optional provenance must not prevent opening the library. } + return imports; } OpdsImportIdentity? capture(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link) { final scope = _scope; - if (scope == null || publication.id.isEmpty) return null; + + if (scope == null || publication.id.isEmpty) { + return null; + } + return OpdsImportIdentity(scope, _generation, [ catalog.id, catalog.uri.toString(), @@ -73,21 +94,36 @@ class OpdsLibrary extends ChangeNotifier { } String? bookId(OpdsCatalog catalog, OpdsPublication publication, {OpdsLink? link}) { - if (_scope == null || !_dataStore.isLoaded || publication.id.isEmpty) return null; + if (_scope == null || !_dataStore.isLoaded || publication.id.isEmpty) { + return null; + } + for (final entry in _imports.entries) { final parts = (jsonDecode(entry.key) as List).cast(); + if (parts.length != 4 || parts[0] != catalog.id || parts[1] != '${catalog.uri}' || parts[2] != publication.id) { continue; } - if (link != null ? parts[3] != '${link.uri}' : entry.value['sample'] == true) continue; + + if (link != null ? parts[3] != '${link.uri}' : entry.value['sample'] == true) { + continue; + } + final id = entry.value['book'] as String; - if (_dataStore.getBook(id) != null) return id; + + if (_dataStore.getBook(id) != null) { + return id; + } } + return null; } Future record(OpdsImportIdentity identity, String bookId) async { - if (_disposed || identity.scope != _scope || identity.generation != _generation) return; + if (_disposed || identity.scope != _scope || identity.generation != _generation) { + return; + } + _imports[jsonEncode(identity.parts)] = {'book': bookId, 'sample': identity.sample}; _changed(); await _persist(identity.scope, _imports); @@ -97,7 +133,11 @@ class OpdsLibrary extends ChangeNotifier { Future forgetCatalog(String catalogId, {required String scope}) async { final imports = _scopedImports.putIfAbsent(scope, () => _read(scope)); imports.removeWhere((key, _) => (jsonDecode(key) as List).first == catalogId); - if (_scope == scope) _generation++; + + if (_scope == scope) { + _generation++; + } + _changed(); await _persist(scope, imports); } @@ -105,6 +145,7 @@ class OpdsLibrary extends ChangeNotifier { Future _persist(String scope, Map> imports) { final json = jsonEncode(imports); // Serialize snapshots, including when an older scope's write is still pending. + _pending = _pending.then((_) async { try { await _prefs.setString(_key(scope), json); @@ -112,11 +153,14 @@ class OpdsLibrary extends ChangeNotifier { // The imported book remains valid even if optional local metadata cannot save. } }); + return _pending; } void _changed() { - if (!_disposed) notifyListeners(); + if (!_disposed) { + notifyListeners(); + } } @override diff --git a/app/lib/opds/opds_models.dart b/app/lib/opds/opds_models.dart index c85fd031..73e434e0 100644 --- a/app/lib/opds/opds_models.dart +++ b/app/lib/opds/opds_models.dart @@ -65,7 +65,11 @@ class OpdsLink { 'download', 'preview', }; - if (indirect || templated || !rels.any(directRelations.contains)) return null; + + if (indirect || templated || !rels.any(directRelations.contains)) { + return null; + } + const extensions = { 'application/epub+zip': 'epub', 'application/pdf': 'pdf', @@ -77,11 +81,17 @@ class OpdsLink { 'application/vnd.comicbook-rar': 'cbr', 'text/plain': 'txt', }; + final mime = type?.split(';').first.trim().toLowerCase(); - if (extensions.containsKey(mime)) return extensions[mime]; + + if (extensions.containsKey(mime)) { + return extensions[mime]; + } + if (mime != null && mime.isNotEmpty && mime != 'application/octet-stream') { return null; } + final extension = uri.path.split('.').last.toLowerCase(); return extensions.values.contains(extension) ? extension : null; } @@ -130,6 +140,7 @@ class OpdsPublication { OpdsLink? get detailLink { for (final link in links) { final type = link.type?.toLowerCase() ?? ''; + if ((link.hasRel('alternate') || link.hasRel('self')) && (type.startsWith('application/opds-publication+json') || (type.startsWith('application/atom+xml') && RegExp(r'type\s*=\s*"?entry').hasMatch(type)))) { @@ -139,9 +150,11 @@ class OpdsPublication { // Some catalogs represent a book's editions in an acquisition feed rather // than a standalone entry. Author metadata distinguishes these partial // publications from category links with decorative thumbnails. + if (authors.isNotEmpty) { for (final link in links) { final type = link.type?.toLowerCase() ?? ''; + if (link.hasRel('alternate') && type.split(';').first.trim() == 'application/atom+xml' && RegExp(r';\s*kind\s*=\s*"?acquisition"?\s*(?:;|$)').hasMatch(type)) { @@ -149,6 +162,7 @@ class OpdsPublication { } } } + return null; } } @@ -194,20 +208,28 @@ class OpdsFeed { OpdsLink? _link(String rel) { for (final link in links) { - if (link.hasRel(rel)) return link; + if (link.hasRel(rel)) { + return link; + } } + return null; } OpdsLink? get searchLink { for (final link in links.where((link) => link.hasRel('search'))) { final mime = link.type?.split(';').first.trim().toLowerCase(); - if (mime == 'application/opensearchdescription+xml') return link; + + if (mime == 'application/opensearchdescription+xml') { + return link; + } + if ((mime == null || mime.isEmpty || mime == 'application/opds+json' || mime == 'application/atom+xml') && RegExp(r'\{[^}]*\b(query|searchTerms)\b').hasMatch(link.template)) { return link; } } + return null; } @@ -219,19 +241,26 @@ class OpdsFeed { String opdsPlainText(String input) { var text = input.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); final hasMarkup = RegExp(r']*|/?)>').hasMatch(text); + text = text .replaceAll(RegExp(r''), '') .replaceAll( RegExp(r'<(?:[\w.-]+:)?(script|style)\b[^>]*>[\s\S]*?', caseSensitive: false), '', ); + if (hasMarkup) { // Indentation and wrapped source lines are not visual paragraph breaks. text = text.replaceAll(RegExp(r'\s+'), ' '); + text = text.replaceAllMapped(RegExp(r'<(/?)(?:[\w.-]+:)?([a-zA-Z][\w.-]*)\b[^>]*>', caseSensitive: false), (match) { final closing = match[1] == '/'; final tag = match[2]!.toLowerCase(); - if (tag == 'br') return '\n'; + + if (tag == 'br') { + return '\n'; + } + if ({ 'p', 'div', @@ -256,20 +285,34 @@ String opdsPlainText(String input) { }.contains(tag)) { return '\n\n'; } - if ({'li', 'dt', 'dd', 'tr'}.contains(tag)) return closing ? '\n' : ''; - if (tag == 'td' || tag == 'th') return closing ? ' ' : ''; + + if ({'li', 'dt', 'dd', 'tr'}.contains(tag)) { + return closing ? '\n' : ''; + } + + if (tag == 'td' || tag == 'th') { + return closing ? ' ' : ''; + } + return ''; }); } + text = text.replaceAllMapped(RegExp(r'&(#x[\da-fA-F]+|#\d+|amp|lt|gt|quot|apos|nbsp);'), (match) { const named = {'amp': '&', 'lt': '<', 'gt': '>', 'quot': '"', 'apos': "'", 'nbsp': ' '}; final entity = match[1]!; - if (named.containsKey(entity)) return named[entity]!; + + if (named.containsKey(entity)) { + return named[entity]!; + } + final code = entity.startsWith('#x') ? int.tryParse(entity.substring(2), radix: 16) : int.tryParse(entity.substring(1)); + return code != null && code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : match[0]!; }); + return text .replaceAll(RegExp(r'[^\S\n]+'), ' ') .replaceAll(RegExp(r' *\n *'), '\n') @@ -278,9 +321,13 @@ String opdsPlainText(String input) { } String? opdsIsbn(String? identifier) { - if (identifier == null) return null; + if (identifier == null) { + return null; + } + final normalized = identifier .replaceFirst(RegExp(r'^(?:urn:)?isbn:', caseSensitive: false), '') .replaceAll(RegExp(r'[\s-]'), ''); + return RegExp(r'^(?:\d{13}|\d{9}[\dXx])$').hasMatch(normalized) ? normalized.toUpperCase() : null; } diff --git a/app/lib/opds/opds_parser.dart b/app/lib/opds/opds_parser.dart index ccede8a3..5df1bd24 100644 --- a/app/lib/opds/opds_parser.dart +++ b/app/lib/opds/opds_parser.dart @@ -6,10 +6,15 @@ class OpdsParser { /// Inspect the document as catalogs commonly use generic HTTP media types. static OpdsFeed parse(String body, Uri uri, {String? contentType}) { final text = body.replaceFirst(RegExp(r'^\uFEFF'), '').trimLeft(); + if (text.startsWith('{')) { return OpdsJsonParser.parse(text, uri, contentType: contentType); } - if (text.startsWith('<')) return OpdsXmlParser.parse(text, uri); + + if (text.startsWith('<')) { + return OpdsXmlParser.parse(text, uri); + } + throw const FormatException('The response is not an OPDS catalog.'); } } diff --git a/app/lib/opds/opds_publication_content.dart b/app/lib/opds/opds_publication_content.dart index 02b9b8e3..2735db85 100644 --- a/app/lib/opds/opds_publication_content.dart +++ b/app/lib/opds/opds_publication_content.dart @@ -12,8 +12,11 @@ class OpdsPublicationContent { factory OpdsPublicationContent.from(OpdsCatalog catalog, OpdsPublication publication) { final information = {}; + void add(String label, String? value) { - if (value != null && value.trim().isNotEmpty) information[label] = value.trim(); + if (value != null && value.trim().isNotEmpty) { + information[label] = value.trim(); + } } final formats = publication.links @@ -21,8 +24,15 @@ class OpdsPublicationContent { .map((link) => link.supportedExtension?.toUpperCase()) .whereType() .toSet(); - if (formats.isNotEmpty) add('Formats', formats.join(', ')); - if (publication.numberOfPages != null) add('Pages', '${publication.numberOfPages}'); + + if (formats.isNotEmpty) { + add('Formats', formats.join(', ')); + } + + if (publication.numberOfPages != null) { + add('Pages', '${publication.numberOfPages}'); + } + add('Publisher', publication.publisher); add('Published', _date(publication.published)); add('ISBN', publication.isbn); @@ -31,6 +41,7 @@ class OpdsPublicationContent { final subjects = publication.subjects.where((subject) => subject.trim().isNotEmpty).toSet(); final original = publication.description?.trim(); final host = catalog.uri.host.toLowerCase(); + if (original == null || original.isEmpty || !(host == 'gutenberg.org' || host.endsWith('.gutenberg.org'))) { return OpdsPublicationContent._(original, information, subjects.toList()); } @@ -39,6 +50,7 @@ class OpdsPublicationContent { // its known labels; do not infer metadata from arbitrary catalogs or prose. final paragraphs = original.split(RegExp(r'\n\s*\n')); final labeled = RegExp(r'^([^:\n]{1,30}):\s*([\s\S]+)$'); + final known = { 'Summary', 'Title', @@ -55,31 +67,43 @@ class OpdsPublicationContent { 'LoCC', 'Category', }; + final recognized = paragraphs.where((paragraph) => known.contains(labeled.firstMatch(paragraph)?[1])).length; - if (recognized < 2) return OpdsPublicationContent._(original, information, subjects.toList()); + + if (recognized < 2) { + return OpdsPublicationContent._(original, information, subjects.toList()); + } final summary = []; final prose = []; final extracted = {}; + void extract(String label, String value) { extracted[label] = _combine(extracted[label], value); } for (final paragraph in paragraphs) { final match = labeled.firstMatch(paragraph); + if (match == null) { prose.add(paragraph); continue; } + final label = match[1]!; final value = match[2]!.trim(); + switch (label) { case 'Summary': summary.add(value); case 'Title': - if (value != publication.title) extract('Catalog title', value); + if (value != publication.title) { + extract('Catalog title', value); + } case 'Author': - if (!publication.authors.contains(value)) extract('Author', value); + if (!publication.authors.contains(value)) { + extract('Author', value); + } case 'Subject': subjects.add(value); case 'EBook No.': @@ -97,6 +121,7 @@ class OpdsPublicationContent { prose.add(paragraph); } } + for (final entry in extracted.entries) { final label = switch (entry.key) { 'EBook No.' => 'Gutenberg ID', @@ -104,8 +129,10 @@ class OpdsPublicationContent { 'LoCC' => 'Classification', _ => entry.key, }; + final value = label == 'Published' ? _date(entry.value)! : entry.value; final existing = information[label]; + if (label == 'Language' && existing != null && existing != value) { information[label] = '$value ($existing)'; } else { @@ -114,21 +141,26 @@ class OpdsPublicationContent { } // These category labels can also appear among Atom subjects. They now have // dedicated metadata rows, so avoid repeating them as subject chips. + for (final label in ['Classification', 'Category']) { subjects.removeAll(information[label]?.split('\n') ?? const []); } + return OpdsPublicationContent._([...summary, ...prose].join('\n\n'), information, subjects.toList()); } - static String _combine(String? existing, String value) => existing == null - ? value - : existing == value || existing.split('\n').contains(value) - ? existing - : '$existing\n$value'; + static String _combine(String? existing, String value) => switch (existing) { + null => value, + final current when current == value || current.split('\n').contains(value) => current, + final current => '$current\n$value', + }; static String? _date(String? value) { - if (value == null) return null; + if (value == null) { + return null; + } // Partial dates (e.g. just a year) keep their original precision. + final date = RegExp(r'^\d{4}-\d{2}-\d{2}(?:T.*)?$').hasMatch(value) ? DateTime.tryParse(value) : null; return date == null ? value : DateFormat.yMMMMd().format(date); } diff --git a/app/lib/opds/opds_resource_cache.dart b/app/lib/opds/opds_resource_cache.dart index ab55e5c1..8d14409f 100644 --- a/app/lib/opds/opds_resource_cache.dart +++ b/app/lib/opds/opds_resource_cache.dart @@ -49,7 +49,10 @@ class OpdsResourceCache extends ChangeNotifier { int _epoch = 0; void setScope(String? scope) { - if (_scope == scope) return; + if (_scope == scope) { + return; + } + _scope = scope; _epoch++; notifyListeners(); @@ -57,10 +60,15 @@ class OpdsResourceCache extends ChangeNotifier { OpdsCacheToken? capture(OpdsCatalog catalog, Uri uri) { final scope = _scope; - if (scope == null) return null; + + if (scope == null) { + return null; + } + OpdsHttpClient.validateUri(catalog.uri); OpdsHttpClient.validateUri(uri); final catalogKey = jsonEncode([scope, catalog.id]); + return OpdsCacheToken._( this, scope, @@ -79,9 +87,16 @@ class OpdsResourceCache extends ChangeNotifier { token._generation == (_generations[token._catalogKey] ?? 0); OpdsCachedResource? read(OpdsCacheToken token) { - if (!isCurrent(token)) return null; + if (!isCurrent(token)) { + return null; + } + final entry = _entries.remove(token._key); - if (entry == null) return null; + + if (entry == null) { + return null; + } + try { final resource = _decode(entry); _entries[token._key] = entry; @@ -92,9 +107,13 @@ class OpdsResourceCache extends ChangeNotifier { } Future write(OpdsCacheToken token, OpdsResponse response) { - if (!isCurrent(token) || response.bytes.length > maxEntryBytes || maxEntries <= 0) return Future.value(); + if (!isCurrent(token) || response.bytes.length > maxEntryBytes || maxEntries <= 0) { + return Future.value(); + } + try { OpdsHttpClient.validateUri(response.uri); + final entry = { 'key': token._key, 'uri': response.uri.toString(), @@ -102,7 +121,11 @@ class OpdsResourceCache extends ChangeNotifier { 'bytes': base64Encode(response.bytes), 'at': DateTime.now().toUtc().millisecondsSinceEpoch, }; - if (utf8.encode(jsonEncode(entry)).length > maxEntryBytes) return Future.value(); + + if (utf8.encode(jsonEncode(entry)).length > maxEntryBytes) { + return Future.value(); + } + _entries.remove(token._key); _entries[token._key] = entry; _trim(); @@ -113,20 +136,29 @@ class OpdsResourceCache extends ChangeNotifier { } Future remove(OpdsCacheToken token) { - if (!isCurrent(token)) return Future.value(); + if (!isCurrent(token)) { + return Future.value(); + } + _entries.remove(token._key); return _persist(); } Future invalidateCatalog(OpdsCatalog catalog, {String? scope}) { scope ??= _scope; - if (scope == null) return Future.value(); + + if (scope == null) { + return Future.value(); + } + final catalogKey = jsonEncode([scope, catalog.id]); _generations[catalogKey] = (_generations[catalogKey] ?? 0) + 1; + _entries.removeWhere((key, _) { final parts = jsonDecode(key) as List; return parts[0] == scope && parts[1] == catalog.id; }); + notifyListeners(); return _persist(); } @@ -141,11 +173,19 @@ class OpdsResourceCache extends ChangeNotifier { OpdsCachedResource _decode(Map entry) { final parts = jsonDecode(entry['key'] as String) as List; - if (parts.length != 4 || parts.any((part) => part is! String)) throw const FormatException('Invalid cache key'); + + if (parts.length != 4 || parts.any((part) => part is! String)) { + throw const FormatException('Invalid cache key'); + } + OpdsHttpClient.validateUri(Uri.parse(parts[2] as String)); OpdsHttpClient.validateUri(Uri.parse(parts[3] as String)); final bytes = base64Decode(entry['bytes'] as String); - if (bytes.length > maxEntryBytes) throw const FormatException('Oversized cache entry'); + + if (bytes.length > maxEntryBytes) { + throw const FormatException('Oversized cache entry'); + } + return OpdsCachedResource( response: OpdsResponse( uri: OpdsHttpClient.validateUri(Uri.parse(entry['uri'] as String)), @@ -159,19 +199,32 @@ class OpdsResourceCache extends ChangeNotifier { void _restore() { try { final raw = _prefs.getString(_storageKey); - if (raw == null || utf8.encode(raw).length > maxBytes) return; + + if (raw == null || utf8.encode(raw).length > maxBytes) { + return; + } + final payload = jsonDecode(raw) as Map; - if (payload['version'] != 1) return; + + if (payload['version'] != 1) { + return; + } + for (final rawEntry in payload['entries'] as List) { try { final entry = Map.from(rawEntry as Map); - if (utf8.encode(jsonEncode(entry)).length > maxEntryBytes) continue; + + if (utf8.encode(jsonEncode(entry)).length > maxEntryBytes) { + continue; + } + _decode(entry); _entries[entry['key'] as String] = entry; } catch (_) { // One damaged entry does not discard other catalogs. } } + _trim(); } catch (_) { _entries.clear(); @@ -183,11 +236,15 @@ class OpdsResourceCache extends ChangeNotifier { _pending = _pending.then((_) async { try { final payload = _serialize(); + if (_entries.isEmpty || utf8.encode(payload).length > maxBytes) { await _prefs.remove(_storageKey); } else { final saved = await _prefs.setString(_storageKey, payload); - if (!saved) await _prefs.remove(_storageKey); + + if (!saved) { + await _prefs.remove(_storageKey); + } } } catch (_) { // Prefer a missing disk cache to old protected data after invalidation. @@ -198,6 +255,7 @@ class OpdsResourceCache extends ChangeNotifier { } } }); + return _pending; } } diff --git a/app/lib/opds/opds_search.dart b/app/lib/opds/opds_search.dart index 8f2fcf70..f2b1a6f7 100644 --- a/app/lib/opds/opds_search.dart +++ b/app/lib/opds/opds_search.dart @@ -4,16 +4,24 @@ import 'opds_models.dart'; import 'opds_xml_parser.dart' show xmlBase; class OpdsSearch { + static const _openSearchNamespace = 'http://a9.com/-/spec/opensearch/1.1/'; + static String expand(String template, String query) { final decoded = template .replaceAll(RegExp('%7b', caseSensitive: false), '{') .replaceAll(RegExp('%7d', caseSensitive: false), '}'); + return decoded.replaceAllMapped(RegExp(r'\{([^{}]+)\}'), (match) { final expression = match[1]!; + if (expression == 'searchTerms' || expression == 'searchTerms?') { return _encode(query); } - if (expression.endsWith('?')) return ''; + + if (expression.endsWith('?')) { + return ''; + } + const defaults = { 'startIndex': '1', 'startPage': '1', @@ -22,10 +30,15 @@ class OpdsSearch { 'inputEncoding': 'UTF-8', 'outputEncoding': 'UTF-8', }; - if (defaults.containsKey(expression)) return defaults[expression]!; + + if (defaults.containsKey(expression)) { + return defaults[expression]!; + } + if (RegExp(r'^[+#./;?&]|(^|,)query(?:[,*:]|$)').hasMatch(expression)) { return _expandScalar(expression, query); } + throw FormatException('Unsupported required search parameter: $expression'); }); } @@ -35,25 +48,37 @@ class OpdsSearch { final operator = operators.contains(expression[0]) ? expression[0] : ''; final variables = (operator.isEmpty ? expression : expression.substring(1)).split(','); final values = []; + for (final variable in variables) { final match = RegExp(r'^query(?::([1-9][0-9]{0,3})|\*)?$').firstMatch(variable); - if (match == null) continue; + + if (match == null) { + continue; + } + final prefix = match[1] == null ? null : int.parse(match[1]!); final value = prefix == null ? query : String.fromCharCodes(query.runes.take(prefix)); final encoded = _encode(value, allowReserved: operator == '+' || operator == '#'); + values.add(switch (operator) { '?' || '&' => 'query=$encoded', ';' => value.isEmpty ? 'query' : 'query=$encoded', _ => encoded, }); } - if (values.isEmpty) return ''; + + if (values.isEmpty) { + return ''; + } + final prefix = operator == '+' ? '' : operator; + final separator = switch (operator) { '?' || '&' => '&', '.' || '/' || ';' => operator, _ => ',', }; + return '$prefix${values.join(separator)}'; } @@ -63,7 +88,11 @@ class OpdsSearch { final encoded = Uri.encodeComponent( value, ).replaceAllMapped(RegExp(r"[!'()*]"), (match) => '%${match[0]!.codeUnitAt(0).toRadixString(16).toUpperCase()}'); - if (!allowReserved) return encoded; + + if (!allowReserved) { + return encoded; + } + return encoded .replaceAllMapped(RegExp(r'%[0-9A-F]{2}'), (match) { final char = String.fromCharCode(int.parse(match[0]!.substring(1), radix: 16)); @@ -74,33 +103,39 @@ class OpdsSearch { static OpdsLink fromOpenSearch(String xml, Uri uri) { final XmlElement root; + try { root = XmlDocument.parse(xml).rootElement; } on XmlException { throw const FormatException('The OpenSearch description is malformed.'); } - const namespace = 'http://a9.com/-/spec/opensearch/1.1/'; - if (root.name.local != 'OpenSearchDescription' || root.name.namespaceUri != namespace) { + + if (root.name.local != 'OpenSearchDescription' || root.name.namespaceUri != _openSearchNamespace) { throw const FormatException('The response is not an OpenSearch description.'); } + final base = xmlBase(root, uri); + for (final element in root.childElements) { - if (element.name.local != 'Url' || element.name.namespaceUri != namespace) continue; + if (element.name.local != 'Url' || element.name.namespaceUri != _openSearchNamespace) { + continue; + } + final method = element.getAttribute('method')?.toUpperCase() ?? 'GET'; final type = element.getAttribute('type'); final mime = type?.split(';').first.trim().toLowerCase(); final template = element.getAttribute('template'); - if (method != 'GET' || - template == null || - !{'application/atom+xml', 'application/opds+json'}.contains(mime) || - !RegExp(r'\{(?:searchTerms\??|query)\}').hasMatch(template)) { + + if (template == null || !_isSupportedSearchEndpoint(method, template, mime)) { continue; } + try { expand(template, ''); } on FormatException { continue; } + return OpdsLink( uri: xmlBase(element, base).resolve(template), type: type, @@ -108,6 +143,19 @@ class OpdsSearch { templated: true, ); } + throw const FormatException('No supported OPDS GET search endpoint was found.'); } + + static bool _isSupportedSearchEndpoint(String method, String template, String? mime) { + if (method != 'GET') { + return false; + } + + if (!{'application/atom+xml', 'application/opds+json'}.contains(mime)) { + return false; + } + + return RegExp(r'\{(?:searchTerms\??|query)\}').hasMatch(template); + } } diff --git a/app/lib/opds/opds_xml_parser.dart b/app/lib/opds/opds_xml_parser.dart index 40442488..821bb6c3 100644 --- a/app/lib/opds/opds_xml_parser.dart +++ b/app/lib/opds/opds_xml_parser.dart @@ -9,26 +9,33 @@ const _dc = {'http://purl.org/dc/terms/', 'http://purl.org/dc/elements/1.1/'}; class OpdsXmlParser { static OpdsFeed parse(String body, Uri uri) { final XmlElement root; + try { root = XmlDocument.parse(body).rootElement; } on XmlException { throw const FormatException('The OPDS XML document is malformed.'); } + if (root.name.namespaceUri != _atom || !{'feed', 'entry'}.contains(root.name.local)) { throw const FormatException('The response is not an Atom OPDS document.'); } + final base = xmlBase(root, uri); final title = _text(root, 'title') ?? 'Untitled catalog'; + if (root.name.local == 'entry') { return OpdsFeed(uri: uri, title: title, publications: [_publication(root, base)]); } + final links = _links(root, base); final navigation = []; final publications = []; final groups = {}; + for (final entry in _children(root, 'entry')) { final book = _publication(entry, xmlBase(entry, base)); final isPublication = book.links.any((link) => link.isAcquisition) || book.detailLink != null; + final entryNavigation = isPublication ? [] : book.links @@ -45,9 +52,14 @@ class OpdsXmlParser { ), ) .toList(); + final collections = book.links.where((link) => link.hasRel('collection')).toList(); + if (collections.isEmpty) { - if (isPublication) publications.add(book); + if (isPublication) { + publications.add(book); + } + navigation.addAll(entryNavigation); } else { for (final collection in collections) { @@ -55,18 +67,29 @@ class OpdsXmlParser { collection.uri.toString(), () => _GroupBuilder(collection.title ?? 'Collection', [collection]), ); - if (isPublication) group.publications.add(book); + + if (isPublication) { + group.publications.add(book); + } + group.navigation.addAll(entryNavigation); } } } + final facets = >{}; + for (final element in _children(root, 'link')) { final link = _link(element, base); - if (link == null || !link.hasRel('http://opds-spec.org/facet')) continue; + + if (link == null || !link.hasRel('http://opds-spec.org/facet')) { + continue; + } + final group = element.getAttribute('facetGroup', namespace: _opds) ?? 'Filters'; facets.putIfAbsent(group, () => []).add(link); } + return OpdsFeed( uri: uri, title: title, @@ -85,6 +108,7 @@ class OpdsXmlParser { link.hasRel('http://opds-spec.org/image/thumbnail')) { return false; } + final mime = link.type?.split(';').first.trim().toLowerCase(); return mime == null || mime == 'application/atom+xml' || mime == 'application/opds+json'; } @@ -92,14 +116,21 @@ class OpdsXmlParser { static OpdsPublication _publication(XmlElement entry, Uri base) { final links = _links(entry, base); final title = _text(entry, 'title') ?? 'Untitled publication'; + final identifiers = entry.childElements.where( (element) => element.name.local == 'identifier' && _dc.contains(element.name.namespaceUri), ); + String? isbn; + for (final identifier in identifiers) { isbn = opdsIsbn(identifier.innerText); - if (isbn != null) break; + + if (isbn != null) { + break; + } } + return OpdsPublication( id: _text(entry, 'id') ?? (links.isNotEmpty ? links.first.uri.toString() : title), title: title, @@ -131,7 +162,11 @@ class OpdsXmlParser { static OpdsLink? _link(XmlElement element, Uri base) { final href = element.getAttribute('href'); - if (href == null || href.trim().isEmpty) return null; + + if (href == null || href.trim().isEmpty) { + return null; + } + return OpdsLink( uri: xmlBase(element, base).resolve(href), title: element.getAttribute('title'), @@ -149,7 +184,11 @@ class OpdsXmlParser { static String? _text(XmlElement parent, String local) { final children = _children(parent, local); - if (children.isEmpty) return null; + + if (children.isEmpty) { + return null; + } + final element = children.first; final text = opdsPlainText(element.getAttribute('type') == 'xhtml' ? element.innerXml : element.innerText); return text.isEmpty ? null : text; @@ -159,6 +198,7 @@ class OpdsXmlParser { final children = parent.childElements.where( (child) => child.name.local == local && _dc.contains(child.name.namespaceUri), ); + return children.isEmpty ? null : opdsPlainText(children.first.innerText); } } diff --git a/app/lib/pages/acquisition_page.dart b/app/lib/pages/acquisition_page.dart index 01ead90f..1179f36d 100644 --- a/app/lib/pages/acquisition_page.dart +++ b/app/lib/pages/acquisition_page.dart @@ -41,7 +41,11 @@ class _AcquisitionPageState extends State { void didChangeDependencies() { super.didChangeDependencies(); final config = context.read().activeApiConfig; - if (_clientBaseUri == config.serverBaseUri) return; + + if (_clientBaseUri == config.serverBaseUri) { + return; + } + _client?.close(); _client = widget.clientFactory?.call(config) ?? AcquisitionApiClient(config: config); _clientBaseUri = config.serverBaseUri; @@ -74,52 +78,86 @@ class _AcquisitionPageState extends State { try { final capabilities = await _authenticated(_apiClient.capabilities); final endpoints = await _authenticated(_apiClient.listEndpoints); - if (!mounted) return; + + if (!mounted) { + return; + } + setState(() { _capabilities = capabilities; _endpoints = endpoints; }); } on AuthApiException catch (error) { - if (!mounted) return; + if (!mounted) { + return; + } + setState(() => _error = _messageFor(error)); } catch (_) { - if (!mounted) return; + if (!mounted) { + return; + } + setState(() { _error = 'This Papyrus server does not expose the torrent acquisition API.'; }); } finally { - if (mounted) setState(() => _loading = false); + if (mounted) { + setState(() => _loading = false); + } } } Future _runArrCommand(AcquisitionEndpoint endpoint) async { final capabilities = _capabilities; final commands = capabilities?.arrCommands[endpoint.kind] ?? const []; - if (commands.isEmpty) return; + + if (commands.isEmpty) { + return; + } final command = await _pickArrCommand(endpoint, commands); - if (command == null) return; + + if (command == null) { + return; + } final ids = await _askForIds(command); - if (ids == null) return; + + if (ids == null) { + return; + } final submissionKey = 'arr:${endpoint.id}'; - if (_submittingKeys.contains(submissionKey)) return; + + if (_submittingKeys.contains(submissionKey)) { + return; + } setState(() => _submittingKeys.add(submissionKey)); + try { final job = await _authenticated((token) { return _apiClient.runArrCommand(accessToken: token, endpointId: endpoint.id, command: command, ids: ids); }); - if (!mounted) return; + + if (!mounted) { + return; + } _showMessage(job.isSubmitted ? '$command sent to ${endpoint.name}.' : job.error ?? 'Arr action failed.'); } on AuthApiException catch (error) { - if (mounted) _showMessage(error.message); + if (mounted) { + _showMessage(error.message); + } } catch (_) { - if (mounted) _showMessage('Could not run this Arr action.'); + if (mounted) { + _showMessage('Could not run this Arr action.'); + } } finally { - if (mounted) setState(() => _submittingKeys.remove(submissionKey)); + if (mounted) { + setState(() => _submittingKeys.remove(submissionKey)); + } } } @@ -139,9 +177,17 @@ class _AcquisitionPageState extends State { Future _showEndpointSheet({AcquisitionEndpoint? endpoint, List? allowedKinds}) async { final capabilities = _capabilities; - if (capabilities == null || capabilities.endpointKinds.isEmpty) return; + + if (capabilities == null || capabilities.endpointKinds.isEmpty) { + return; + } + final endpointKinds = allowedKinds ?? capabilities.endpointKinds; - if (endpointKinds.isEmpty) return; + + if (endpointKinds.isEmpty) { + return; + } + final downloadsProvider = context.read(); final saved = await showAcquisitionEndpointEditor( @@ -185,6 +231,7 @@ class _AcquisitionPageState extends State { username: username, password: password, ); + return; } @@ -212,12 +259,16 @@ class _AcquisitionPageState extends State { Future _deleteEndpoint(AcquisitionEndpoint endpoint) async { final downloadsProvider = context.read(); final confirmed = await showAcquisitionRemoveDialog(context: context, endpointName: endpoint.name); - if (confirmed != true) return; + + if (confirmed != true) { + return; + } try { await _authenticated((token) { return _apiClient.deleteEndpoint(accessToken: token, endpointId: endpoint.id); }); + await _load(); await downloadsProvider?.refreshConfiguration(); } catch (_) { @@ -338,9 +389,17 @@ class _AcquisitionPageState extends State { popUpAnimationStyle: AppMotion.animationStyle(context), tooltip: 'Actions for ${endpoint.name}', onSelected: (value) { - if (value == 'edit') _showEndpointSheet(endpoint: endpoint); - if (value == 'run' && runEnabled) _runArrCommand(endpoint); - if (value == 'delete') _deleteEndpoint(endpoint); + if (value == 'edit') { + _showEndpointSheet(endpoint: endpoint); + } + + if (value == 'run' && runEnabled) { + _runArrCommand(endpoint); + } + + if (value == 'delete') { + _deleteEndpoint(endpoint); + } }, itemBuilder: (context) => [ const PopupMenuItem(value: 'edit', child: Text('Edit')), @@ -354,6 +413,7 @@ class _AcquisitionPageState extends State { if (error.statusCode == 404) { return 'This Papyrus server does not expose the torrent acquisition API.'; } + return error.message; } @@ -395,6 +455,7 @@ class _ErrorBanner extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return Card( color: colorScheme.errorContainer, child: ListTile( diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index 375b2181..74eb96f7 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -66,16 +66,13 @@ class _AnnotationsPageState extends State { if (isDesktop) { return _buildDesktopLayout(context, provider); } + return _buildMobileLayout(context, provider); }, ), ); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout(BuildContext context, AnnotationsProvider provider) { return Scaffold( key: _scaffoldKey, @@ -111,10 +108,6 @@ class _AnnotationsPageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout(BuildContext context, AnnotationsProvider provider) { return Scaffold( body: Column( @@ -141,10 +134,6 @@ class _AnnotationsPageState extends State { ); } - // ============================================================================ - // SHARED WIDGETS - // ============================================================================ - Widget _buildSearchField(AnnotationsProvider provider, {Widget? leading}) { return TextField( controller: _searchController, @@ -283,6 +272,7 @@ class _AnnotationsPageState extends State { for (final entry in groups.entries) { final isCollapsed = _collapsedGroups.contains(entry.key); + items.add( BookGroupHeader( bookId: entry.key, @@ -303,6 +293,7 @@ class _AnnotationsPageState extends State { }, ), ); + if (!isCollapsed) { for (final annotation in entry.value) { items.add( @@ -323,10 +314,6 @@ class _AnnotationsPageState extends State { ); } - // ============================================================================ - // ACTIONS - // ============================================================================ - void _navigateToBook(BuildContext context, String bookId) { context.goNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); } @@ -335,7 +322,9 @@ class _AnnotationsPageState extends State { final repository = context.read().libraryRepository?.annotations; final action = await AnnotationActionSheet.show(context, annotation: annotation); - if (action == null || !mounted) return; + if (action == null || !mounted) { + return; + } switch (action) { case AnnotationAction.edit: @@ -347,13 +336,17 @@ class _AnnotationsPageState extends State { void _onEditAnnotation(Annotation annotation, EntityRepository? repository) async { final store = context.read(); + await AnnotationDialog.show( context, bookId: annotation.bookId, existingAnnotation: annotation, onSave: (updated) => store.updateAnnotation(updated, previous: annotation, repository: repository), ); - if (!mounted) return; + + if (!mounted) { + return; + } } void _onDeleteAnnotation( @@ -363,6 +356,7 @@ class _AnnotationsPageState extends State { ) async { final bookTitle = provider.getBookTitle(annotation.bookId); final confirmed = await DeleteAnnotationDialog.show(context, annotation: annotation, bookTitle: bookTitle); + if (confirmed && mounted) { try { await provider.deleteAnnotation(annotation.id, repository: repository); @@ -373,6 +367,7 @@ class _AnnotationsPageState extends State { const SnackBar(content: Text('Could not delete. Please try again.')), ); } + return; } } diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 8d32e643..7b91f063 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -68,6 +68,7 @@ class _BookDetailsPageState extends State with TickerProviderSt void didChangeDependencies() { super.didChangeDependencies(); final duration = AppMotion.duration(context, kTabScrollDuration); + if (_tabController.animationDuration != duration) { final index = _tabController.index; _tabController.removeListener(_onTabChanged); @@ -76,6 +77,7 @@ class _BookDetailsPageState extends State with TickerProviderSt _tabController.addListener(_onTabChanged); } // Connect to DataStore for persistent storage + final dataStore = context.read(); _provider.setDataStore(dataStore); @@ -108,9 +110,11 @@ class _BookDetailsPageState extends State with TickerProviderSt if (provider.isLoading) { return _buildLoadingState(context); } + if (provider.error != null) { return _buildErrorState(context, provider.error!); } + if (!provider.hasBook) { return _buildNotFoundState(context); } @@ -125,6 +129,7 @@ class _BookDetailsPageState extends State with TickerProviderSt if (isDesktop) { return _buildDesktopLayout(context, provider, readingActionState); } + return _buildMobileLayout(context, provider, readingActionState); }, ), @@ -380,13 +385,16 @@ class _BookDetailsPageState extends State with TickerProviderSt } void _onUpdateProgress() { - if (_provider.book == null) return; + if (_provider.book == null) { + return; + } UpdateProgressSheet.show( context, book: _provider.book!, onSave: (page, position) { _provider.updatePageProgress(page, position); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), @@ -399,58 +407,85 @@ class _BookDetailsPageState extends State with TickerProviderSt Future _onContinueReading() async { final book = _provider.book; - if (book == null || _isPreparingReader) return; + + if (book == null || _isPreparingReader) { + return; + } final messenger = ScaffoldMessenger.of(context); + if (ReaderBookAdapter.formatFor(book.fileFormat) == null) { messenger.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('This book format is not supported yet.')), ); + return; } final storageStatusController = context.read(); + final needsDownload = storageStatusController == null ? book.fileMediaId != null : storageStatusController.deviceStatus(book) != BookDeviceStatus.available; + if (needsDownload) { if (book.fileMediaId == null) { setState(() => _readingError = 'This book file is not available yet.'); return; } + setState(() { _isPreparingReader = true; _readingError = null; }); + try { final importService = context.read(); + await context.read().ensureBookFileCached( book, readLocalBookFile: importService.getBookFile, writeLocalBookFile: importService.storeBookFile, downloadMedia: context.read().downloadMedia, ); + storageStatusController?.markAvailable(book.id); } catch (_) { - if (!mounted) return; + if (!mounted) { + return; + } + setState(() { _isPreparingReader = false; _readingError = 'Could not download this book file.'; }); + + return; + } + + if (!mounted) { return; } - if (!mounted) return; + setState(() => _isPreparingReader = false); } - if (!mounted) return; + if (!mounted) { + return; + } + await context.pushNamed('BOOK_READER', pathParameters: {'bookId': book.id}); } BookReadingActionState _readingActionState(BookStorageStatusController? controller, Book book) { - if (_isPreparingReader) return BookReadingActionState.downloading; - if (book.isPhysical || controller == null) return BookReadingActionState.ready; + if (_isPreparingReader) { + return BookReadingActionState.downloading; + } + + if (book.isPhysical || controller == null) { + return BookReadingActionState.ready; + } final accountStatus = controller.accountStatus(book); @@ -476,7 +511,10 @@ class _BookDetailsPageState extends State with TickerProviderSt Future _onDownloadBookFile() async { final book = _provider.book; - if (book == null) return; + + if (book == null) { + return; + } final messenger = ScaffoldMessenger.of(context); final importService = context.read(); @@ -490,13 +528,20 @@ class _BookDetailsPageState extends State with TickerProviderSt try { final cached = await mediaCacheService.getValidCachedBookFile(book, readLocalBookFile: importService.getBookFile); - if (!mounted) return; + + if (!mounted) { + return; + } final bytes = cached ?? await _downloadAndCacheBookFile(book, importService, mediaCacheService); final result = await downloadService.saveBookFile(book: book, bytes: bytes); - if (!mounted) return; + if (!mounted) { + return; + } + messenger.hideCurrentSnackBar(); + if (result.saved) { messenger.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), @@ -509,8 +554,12 @@ class _BookDetailsPageState extends State with TickerProviderSt ); } } catch (_) { - if (!mounted) return; + if (!mounted) { + return; + } + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Could not download this book file.')), @@ -524,6 +573,7 @@ class _BookDetailsPageState extends State with TickerProviderSt MediaCacheService mediaCacheService, ) { final authProvider = context.read(); + return mediaCacheService.ensureBookFileCached( book, readLocalBookFile: importService.getBookFile, @@ -533,9 +583,12 @@ class _BookDetailsPageState extends State with TickerProviderSt } void _onAddNote() async { - if (_provider.book == null) return; + if (_provider.book == null) { + return; + } final repository = context.read().libraryRepository?.notes; + final note = await NoteDialog.show( context, bookId: _provider.book!.id, @@ -551,9 +604,12 @@ class _BookDetailsPageState extends State with TickerProviderSt } void _onAddBookmark() async { - if (_provider.book == null) return; + if (_provider.book == null) { + return; + } final repository = context.read().libraryRepository?.bookmarks; + final bookmark = await BookmarkDialog.show( context, bookId: _provider.book!.id, @@ -570,9 +626,12 @@ class _BookDetailsPageState extends State with TickerProviderSt } void _onAddAnnotation() async { - if (_provider.book == null) return; + if (_provider.book == null) { + return; + } final repository = context.read().libraryRepository?.annotations; + final annotation = await AnnotationDialog.show( context, bookId: _provider.book!.id, @@ -590,7 +649,9 @@ class _BookDetailsPageState extends State with TickerProviderSt void _onNoteActions(Note note) async { final action = await NoteActionSheet.show(context, note: note); - if (action == null || !mounted) return; + if (action == null || !mounted) { + return; + } switch (action) { case NoteAction.edit: @@ -601,9 +662,12 @@ class _BookDetailsPageState extends State with TickerProviderSt } void _onEditNote(Note note) async { - if (_provider.book == null) return; + if (_provider.book == null) { + return; + } final repository = context.read().libraryRepository?.notes; + final updatedNote = await NoteDialog.show( context, bookId: _provider.book!.id, @@ -633,9 +697,14 @@ class _BookDetailsPageState extends State with TickerProviderSt const SnackBar(content: Text('Could not delete. Please try again.')), ); } + + return; + } + + if (!mounted) { return; } - if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Note deleted')), @@ -647,7 +716,9 @@ class _BookDetailsPageState extends State with TickerProviderSt final repository = context.read().libraryRepository?.annotations; final action = await AnnotationActionSheet.show(context, annotation: annotation); - if (action == null || !mounted) return; + if (action == null || !mounted) { + return; + } switch (action) { case AnnotationAction.edit: @@ -665,7 +736,10 @@ class _BookDetailsPageState extends State with TickerProviderSt onSave: (updated) => _provider.updateAnnotation(annotation.id, updated, previous: annotation, repository: repository), ); - if (!mounted) return; + + if (!mounted) { + return; + } } void _onDeleteAnnotation(Annotation annotation, EntityRepository? repository) async { @@ -674,6 +748,7 @@ class _BookDetailsPageState extends State with TickerProviderSt annotation: annotation, bookTitle: _provider.book?.title ?? '', ); + if (confirmed && mounted) { try { await _provider.deleteAnnotation(annotation.id, repository: repository); @@ -684,6 +759,7 @@ class _BookDetailsPageState extends State with TickerProviderSt const SnackBar(content: Text('Could not delete. Please try again.')), ); } + return; } } @@ -693,7 +769,9 @@ class _BookDetailsPageState extends State with TickerProviderSt final repository = context.read().libraryRepository?.bookmarks; final action = await BookmarkActionSheet.show(context, bookmark: bookmark); - if (action == null || !mounted) return; + if (action == null || !mounted) { + return; + } switch (action) { case BookmarkAction.editNote: @@ -737,7 +815,10 @@ class _BookDetailsPageState extends State with TickerProviderSt Future _confirmDeleteBook() async { final book = _provider.book; - if (book == null) return; + + if (book == null) { + return; + } final confirmed = await showDialog( animationStyle: AppMotion.animationStyle(context), @@ -759,7 +840,9 @@ class _BookDetailsPageState extends State with TickerProviderSt ), ); - if (confirmed != true || !mounted) return; + if (confirmed != true || !mounted) { + return; + } final dataStore = context.read(); final mediaUploadQueue = context.read(); @@ -780,8 +863,12 @@ class _BookDetailsPageState extends State with TickerProviderSt deleteCoverFile: mediaScope == null ? null : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), ); - if (!mounted) return; + if (!mounted) { + return; + } + context.go('/library/books'); + messenger.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text('Deleted "${book.title}"')), diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 2eb749b1..daefb301 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -41,6 +41,7 @@ class _BookEditPageState extends State { static const double _minimumDesktopFormPaneWidth = 420; // Section cards already inset their contents by Spacing.md. static const double _desktopPaneGap = Spacing.xl - Spacing.md; + static const double _desktopPaneBreakpoint = _desktopCoverPaneWidth + _desktopPaneGap + _minimumDesktopFormPaneWidth + (Spacing.lg * 2); @@ -86,7 +87,10 @@ class _BookEditPageState extends State { void _populateControllers() { final book = _provider.editedBook; - if (book == null) return; + + if (book == null) { + return; + } _titleController.text = book.title; _subtitleController.text = book.subtitle ?? ''; @@ -97,14 +101,17 @@ class _BookEditPageState extends State { _isbnController.text = book.isbn ?? ''; _isbn13Controller.text = book.isbn13 ?? ''; _descriptionController.text = book.description ?? ''; + _publicationDateController.text = book.publicationDate != null ? DateFormat.yMMMMd().format(book.publicationDate!) : ''; + _seriesNameController.text = book.seriesName ?? ''; _seriesNumberController.text = book.seriesNumber != null ? _formatSeriesNumber(book.seriesNumber!) : ''; _physicalLocationController.text = book.physicalLocation ?? ''; _lentToController.text = book.lentTo ?? ''; _lentAtController.text = book.lentAt != null ? DateFormat.yMMMMd().format(book.lentAt!) : ''; + setState(() { _coAuthors = List.from(book.coAuthors); }); @@ -179,8 +186,12 @@ class _BookEditPageState extends State { return PopScope( canPop: !provider.hasUnsavedChanges, onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; + if (didPop) { + return; + } + final discard = await _showDiscardDialog(); + if (discard && mounted && context.mounted) { _navigateToBookDetails(context); } @@ -213,10 +224,6 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // LAYOUTS - // ============================================================================ - Widget _buildDesktopScaffold(BuildContext context, BookEditProvider provider) { return Form( key: _formKey, @@ -282,6 +289,7 @@ class _BookEditPageState extends State { child: LayoutBuilder( builder: (context, constraints) { final showSideBySide = constraints.maxWidth >= _desktopPaneBreakpoint; + return SingleChildScrollView( padding: const EdgeInsets.all(Spacing.lg), child: showSideBySide @@ -462,10 +470,6 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // SECTION CARD - // ============================================================================ - Widget _buildSectionCard({ required String title, required List children, @@ -487,10 +491,6 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // RATING SECTION - // ============================================================================ - Widget _buildRatingRow(BuildContext context, BookEditProvider provider) { final rating = provider.editedBook?.rating ?? 0; final colorScheme = Theme.of(context).colorScheme; @@ -502,6 +502,7 @@ class _BookEditPageState extends State { ...List.generate(5, (index) { final starValue = index + 1; final isSelected = starValue <= rating; + return GestureDetector( onTap: () { _provider.updateRating(starValue == rating ? null : starValue); @@ -520,10 +521,6 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // PHYSICAL BOOK SECTION - // ============================================================================ - Widget _buildPhysicalBookSection(BuildContext context, BookEditProvider provider) { final isPhysical = provider.editedBook?.isPhysical ?? false; @@ -565,10 +562,6 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // COVER SECTION - // ============================================================================ - Widget _buildCoverSection(BuildContext context, BookEditProvider provider, {required bool isDesktop}) { return CoverImagePicker( bookId: provider.editedBook?.id, @@ -582,12 +575,9 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // METADATA SECTION - // ============================================================================ - Widget _buildMetadataSection(BuildContext context, BookEditProvider provider) { final colorScheme = Theme.of(context).colorScheme; + final sourceSelector = SegmentedButton( segments: const [ ButtonSegment(value: MetadataSource.openLibrary, label: Text('Open Library')), @@ -600,6 +590,7 @@ class _BookEditPageState extends State { }, style: const ButtonStyle(visualDensity: VisualDensity.compact, tapTargetSize: MaterialTapTargetSize.shrinkWrap), ); + final searchField = TextFormField( controller: _metadataSearchController, decoration: InputDecoration( @@ -750,15 +741,15 @@ class _BookEditPageState extends State { ); } - // ============================================================================ - // ACTIONS - // ============================================================================ - void _searchMetadata(BookEditProvider provider) { final query = _metadataSearchController.text.trim(); - if (query.isEmpty) return; + + if (query.isEmpty) { + return; + } final cleanQuery = query.replaceAll(RegExp(r'[-\s]'), ''); + if (RegExp(r'^[0-9X]{10,13}$').hasMatch(cleanQuery)) { provider.searchMetadataByIsbn(query); } else { @@ -768,8 +759,8 @@ class _BookEditPageState extends State { void _applyMetadata(BookEditProvider provider, BookMetadataResult result) { provider.applyMetadata(result); - final book = provider.editedBook; + if (book != null) { _titleController.text = book.title; _subtitleController.text = book.subtitle ?? ''; @@ -780,9 +771,11 @@ class _BookEditPageState extends State { _isbnController.text = book.isbn ?? ''; _isbn13Controller.text = book.isbn13 ?? ''; _descriptionController.text = book.description ?? ''; + _publicationDateController.text = book.publicationDate != null ? DateFormat.yMMMMd().format(book.publicationDate!) : ''; + setState(() { _coAuthors = List.from(book.coAuthors); }); @@ -807,6 +800,7 @@ class _BookEditPageState extends State { ], ), ); + return result ?? false; } @@ -821,8 +815,12 @@ class _BookEditPageState extends State { void _handleCancel(BuildContext context) async { if (_provider.hasUnsavedChanges) { final discard = await _showDiscardDialog(); - if (!discard) return; + + if (!discard) { + return; + } } + if (mounted && context.mounted) { _navigateToBookDetails(context); } @@ -834,6 +832,7 @@ class _BookEditPageState extends State { snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Please fix the errors before saving'), behavior: SnackBarBehavior.floating), ); + return; } @@ -853,8 +852,10 @@ class _BookEditPageState extends State { if (coverBytes != null) { final book = _provider.editedBook!; final importService = context.read(); + if (scope == null) { await importService.storeGuestCoverFile(book.id, coverBytes); + LocalCoverImageProvider.evictKey( scopeKey: MediaStorageScope.localGuest.persistenceKey, bucket: CoverStorageBucket.guestBooks, @@ -862,6 +863,7 @@ class _BookEditPageState extends State { ); } else { await importService.storePendingCoverFile(scope, book.id, coverBytes); + LocalCoverImageProvider.evictKey( scopeKey: scope.persistenceKey, bucket: CoverStorageBucket.pending, @@ -871,15 +873,18 @@ class _BookEditPageState extends State { } final success = await _provider.save(); + if (!success) { if (mounted && context.mounted) { _showSaveError(context, _provider.error ?? 'Failed to save'); } + return; } if (coverBytes != null && scope != null) { final book = _provider.editedBook!; + await queue.enqueueCover( book: book, filename: '${book.id}-cover.${imageFileExtension(coverBytes)}', @@ -890,15 +895,19 @@ class _BookEditPageState extends State { if (mounted && context.mounted) { _showSaveError(context, 'Failed to save cover: $error'); } + return; } - if (!mounted || !context.mounted) return; + if (!mounted || !context.mounted) { + return; + } ScaffoldMessenger.of(context).showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Book updated'), behavior: SnackBarBehavior.floating), ); + _navigateToBookDetails(context); } diff --git a/app/lib/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index cf5b6ba3..3a764b35 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -75,16 +75,13 @@ class _BookmarksPageState extends State { if (isDesktop) { return _buildDesktopLayout(context, provider); } + return _buildMobileLayout(context, provider); }, ), ); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout(BuildContext context, BookmarksProvider provider) { return Scaffold( key: _scaffoldKey, @@ -120,10 +117,6 @@ class _BookmarksPageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout(BuildContext context, BookmarksProvider provider) { return Scaffold( body: Column( @@ -150,10 +143,6 @@ class _BookmarksPageState extends State { ); } - // ============================================================================ - // SHARED WIDGETS - // ============================================================================ - Widget _buildSearchField(BookmarksProvider provider, {Widget? leading}) { return TextField( controller: _searchController, @@ -294,6 +283,7 @@ class _BookmarksPageState extends State { for (final entry in groups.entries) { final isCollapsed = _collapsedGroups.contains(entry.key); + items.add( BookGroupHeader( bookId: entry.key, @@ -314,6 +304,7 @@ class _BookmarksPageState extends State { }, ), ); + if (!isCollapsed) { for (final bookmark in entry.value) { items.add( @@ -335,10 +326,6 @@ class _BookmarksPageState extends State { ); } - // ============================================================================ - // ACTIONS - // ============================================================================ - void _navigateToBook(BuildContext context, String bookId) { context.goNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); } @@ -347,7 +334,9 @@ class _BookmarksPageState extends State { final repository = context.read().libraryRepository?.bookmarks; final action = await BookmarkActionSheet.show(context, bookmark: bookmark); - if (action == null || !mounted) return; + if (action == null || !mounted) { + return; + } switch (action) { case BookmarkAction.editNote: @@ -390,6 +379,7 @@ class _BookmarksPageState extends State { void _onDeleteBookmark(BookmarksProvider provider, Bookmark bookmark, EntityRepository? repository) async { final bookTitle = provider.getBookTitle(bookmark.bookId); + await DeleteBookmarkDialog.show( context, bookmark: bookmark, diff --git a/app/lib/pages/catalog_book_page.dart b/app/lib/pages/catalog_book_page.dart index 54b3e1c3..cfe1f3ad 100644 --- a/app/lib/pages/catalog_book_page.dart +++ b/app/lib/pages/catalog_book_page.dart @@ -25,6 +25,7 @@ class CatalogBookSelection { required this.scope, this.cached = false, }); + final OpdsCatalog catalog; final OpdsPublication publication; final String? scope; @@ -55,6 +56,7 @@ class CatalogBookPage extends StatefulWidget { this.query = '', this.initial, }); + final String catalogId; final Uri? source; final String publicationId; @@ -83,8 +85,13 @@ class _CatalogBookPageState extends State { void _cacheChanged() { final token = _previewCacheToken; - if (!mounted || token == null || _browser.httpClient.cache!.isCurrent(token)) return; + + if (!mounted || token == null || _browser.httpClient.cache!.isCurrent(token)) { + return; + } + _previewCacheToken = null; + setState(() { _publication = null; _loading = false; @@ -94,7 +101,11 @@ class _CatalogBookPageState extends State { void _scheduleLoad(OpdsCatalogs catalogs, OpdsCatalog? catalog) { final key = '${catalogs.scope}/${catalogs.revision}/${widget.catalogId}/${widget.source}/${widget.publicationId}'; - if (key == _loadKey) return; + + if (key == _loadKey) { + return; + } + _loadKey = key; _browser.clear(); _previewCacheToken = null; @@ -103,101 +114,178 @@ class _CatalogBookPageState extends State { _cachedPreview = false; _error = null; _loading = catalog != null; - if (catalog == null) return; + + if (catalog == null) { + return; + } + _previewCacheToken = _browser.httpClient.cache?.capture(catalog, catalog.uri); final initial = widget.initial; + if (initial != null && initial.scope == catalogs.scope && identical(initial.catalog, catalog) && initial.publication.id == widget.publicationId) { _publication = initial.publication; } + final resolveSource = _publication == null || initial?.cached == true; + if (resolveSource) { _publication = _readCachedPublication(catalog) ?? _publication; _cachedPreview = _publication != null; } + WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted || key != _loadKey) return; + if (!mounted || key != _loadKey) { + return; + } + try { final credentials = await catalogs.credentials(catalog.id); - if (!mounted || key != _loadKey) return; + + if (!mounted || key != _loadKey) { + return; + } + _credentials = credentials; + if (resolveSource) { final source = widget.source; + if (source == null || !source.hasScheme || widget.publicationId.isEmpty) { throw const OpdsException('This book link is incomplete. Return to the catalog and select the book again.'); } + await _browser.load(catalog, source, credentials: credentials); - if (!mounted || key != _loadKey) return; - if (_browser.authorizationFailed || _browser.cacheInvalidated) _publication = null; + + if (!mounted || key != _loadKey) { + return; + } + + if (_browser.authorizationFailed || _browser.cacheInvalidated) { + _publication = null; + } + final feed = _browser.feed; - if (feed == null) throw OpdsException(_browser.error ?? 'Could not load this book.'); - if (_browser.error != null) _error = 'Showing saved content. ${_browser.error}'; + + if (feed == null) { + throw OpdsException(_browser.error ?? 'Could not load this book.'); + } + + if (_browser.error != null) { + _error = 'Showing saved content. ${_browser.error}'; + } + _publication = [ ...feed.publications, for (final group in feed.groups) ...group.publications, ].where((book) => book.id == widget.publicationId).firstOrNull; - if (_publication == null) throw const OpdsException('This book is no longer available in this catalog feed.'); + + if (_publication == null) { + throw const OpdsException('This book is no longer available in this catalog feed.'); + } + setState(() {}); } // Resolve standalone publication metadata using the existing OPDS parser // and detail-link classification. Multi-edition feeds remain distinct. + final detail = _publication!.detailLink; + if (detail != null && detail.uri != widget.source) { await _browser.load(catalog, detail.uri, credentials: credentials); - if (!mounted || key != _loadKey) return; + + if (!mounted || key != _loadKey) { + return; + } + if (_browser.authorizationFailed || _browser.cacheInvalidated) { _publication = null; throw OpdsException(_browser.error!); } + final books = _browser.feed?.publications ?? []; final type = detail.type?.toLowerCase() ?? ''; + final standalone = type.startsWith('application/opds-publication+json') || RegExp(r'type\s*=\s*"?entry').hasMatch(type); + final matching = books.where((book) => book.id == _publication!.id).firstOrNull; + if (matching != null || (standalone && books.length == 1)) { _publication = matching ?? books.single; - if (_browser.error != null) _error = 'Showing saved content. ${_browser.error}'; + + if (_browser.error != null) { + _error = 'Showing saved content. ${_browser.error}'; + } } else if (_browser.error != null) { _error = _browser.error; } } } catch (error) { - if (!mounted || key != _loadKey) return; + if (!mounted || key != _loadKey) { + return; + } + _error = opdsErrorMessage(error); } finally { - if (mounted && key == _loadKey) setState(() => _loading = false); + if (mounted && key == _loadKey) { + setState(() => _loading = false); + } } }); } OpdsPublication? _readCachedPublication(OpdsCatalog catalog) { final source = widget.source; - if (source == null) return null; + + if (source == null) { + return null; + } + final feed = _readCachedFeed(catalog, source); - if (feed == null) return null; + + if (feed == null) { + return null; + } + final publication = [ ...feed.publications, for (final group in feed.groups) ...group.publications, ].where((book) => book.id == widget.publicationId).firstOrNull; + final detail = publication?.detailLink; - if (detail == null || detail.uri == source) return publication; + + if (detail == null || detail.uri == source) { + return publication; + } + final books = _readCachedFeed(catalog, detail.uri)?.publications ?? []; final type = detail.type?.toLowerCase() ?? ''; + final standalone = type.startsWith('application/opds-publication+json') || RegExp(r'type\s*=\s*"?entry').hasMatch(type); + return books.where((book) => book.id == publication!.id).firstOrNull ?? (standalone && books.length == 1 ? books.single : publication); } OpdsFeed? _readCachedFeed(OpdsCatalog catalog, Uri source) { final cache = _browser.httpClient.cache; - if (cache == null) return null; + + if (cache == null) { + return null; + } + try { final token = cache.capture(catalog, source); final response = token == null ? null : cache.read(token)?.response; - if (response == null) return null; + + if (response == null) { + return null; + } + return OpdsParser.parse(response.text, response.uri, contentType: response.headers['content-type']); } catch (_) { return null; @@ -217,6 +305,7 @@ class _CatalogBookPageState extends State { path: '/library/catalogs/${Uri.encodeComponent(widget.catalogId)}', queryParameters: {if (uri != null) 'feed': uri.toString(), if (widget.query.isNotEmpty) 'q': widget.query}, ).toString(); + if (replace) { context.go(location); } else { @@ -225,21 +314,32 @@ class _CatalogBookPageState extends State { } Future _download(OpdsCatalog catalog, OpdsPublication publication, OpdsLink link) async { - if (!mounted) return; + if (!mounted) { + return; + } + final catalogs = context.read(); final downloads = context.read(); final scope = catalogs.scope; final revision = catalogs.revision; + if (!identical(catalogs.find(catalog.id), catalog)) { _message('The catalog or account changed. Close these details and reopen the book.'); return; } + try { final credentials = await catalogs.credentials(catalog.id); - if (!mounted || scope != catalogs.scope || revision != catalogs.revision) return; + + if (!mounted || scope != catalogs.scope || revision != catalogs.revision) { + return; + } + await downloads.start(catalog, publication, link, credentials: credentials); } catch (error) { - if (mounted) _message(opdsErrorMessage(error)); + if (mounted) { + _message(opdsErrorMessage(error)); + } } } @@ -247,7 +347,11 @@ class _CatalogBookPageState extends State { void _retry() { final catalogs = context.read(); - if (catalogs.error != null) catalogs.reload(); + + if (catalogs.error != null) { + catalogs.reload(); + } + setState(() => _loadKey = null); } @@ -265,6 +369,7 @@ class _CatalogBookPageState extends State { final downloads = context.watch(); _scheduleLoad(catalogs, catalog); final publication = _publication; + return SafeArea( child: LayoutBuilder( builder: (context, constraints) => Column( @@ -334,23 +439,24 @@ class _CatalogBookPageState extends State { ), ), Expanded( - child: catalog == null - ? _unavailable(catalogs.error ?? 'This catalog is not saved for the active account.') - : _loading && _publication == null - ? const Center(child: AppCircularProgressIndicator()) - : _publication == null - ? _unavailable(_error ?? 'This book is unavailable.') - : OpdsPublicationDetails( - key: ValueKey('${catalogs.scope}/${catalogs.revision}/${_publication!.id}'), - catalog: catalog, - publication: _publication!, - httpClient: _browser.httpClient, - credentials: _credentials, - downloads: downloads, - onNavigate: _navigate, - resolving: _loading && !_cachedPreview, - onDownload: (link) => unawaited(_download(catalog, publication!, link)), - ), + child: switch (catalog) { + null => _unavailable(catalogs.error ?? 'This catalog is not saved for the active account.'), + _ when _loading && _publication == null => const Center( + child: AppCircularProgressIndicator(), + ), + _ when _publication == null => _unavailable(_error ?? 'This book is unavailable.'), + final catalog => OpdsPublicationDetails( + key: ValueKey('${catalogs.scope}/${catalogs.revision}/${_publication!.id}'), + catalog: catalog, + publication: _publication!, + httpClient: _browser.httpClient, + credentials: _credentials, + downloads: downloads, + onNavigate: _navigate, + resolving: _loading && !_cachedPreview, + onDownload: (link) => unawaited(_download(catalog, publication!, link)), + ), + }, ), ], ), diff --git a/app/lib/pages/catalogs_page.dart b/app/lib/pages/catalogs_page.dart index 24a5fb73..4b4b371b 100644 --- a/app/lib/pages/catalogs_page.dart +++ b/app/lib/pages/catalogs_page.dart @@ -29,6 +29,7 @@ class CatalogsPage extends StatefulWidget { final Uri? feedUri; final String query; final OpdsHttpClient? httpClient; + @override State createState() => _CatalogsPageState(); } @@ -47,30 +48,56 @@ class _CatalogsPageState extends State { @override void didUpdateWidget(covariant CatalogsPage oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.query != widget.query) _search.text = widget.query; + + if (oldWidget.query != widget.query) { + _search.text = widget.query; + } } void _scheduleLoad(OpdsCatalogs catalogs) { final catalog = widget.catalogId == null ? null : catalogs.find(widget.catalogId!); final key = '${catalogs.scope}/${catalogs.revision}/${widget.catalogId}/${widget.feedUri ?? catalog?.uri}'; - if (_loadKey == key && !_reloadRequested) return; + + if (_loadKey == key && !_reloadRequested) { + return; + } + final sameFeed = _loadKey == key; _feedForHeader = sameFeed ? (_browser.feed ?? _feedForHeader) : null; _reloadRequested = false; _loadKey = key; _credentials = null; - if (!sameFeed) _browser.clear(); - if (catalog == null) return; + + if (!sameFeed) { + _browser.clear(); + } + + if (catalog == null) { + return; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!mounted || _loadKey != key) return; - if (_feedScroll.hasClients) _feedScroll.jumpTo(0); + if (!mounted || _loadKey != key) { + return; + } + + if (_feedScroll.hasClients) { + _feedScroll.jumpTo(0); + } + try { final credentials = await catalogs.credentials(catalog.id); - if (!mounted || _loadKey != key) return; + + if (!mounted || _loadKey != key) { + return; + } + _credentials = credentials; await _browser.load(catalog, widget.feedUri ?? catalog.uri, credentials: credentials); } catch (error) { - if (mounted && _loadKey == key) setState(() => _browser.error = opdsErrorMessage(error)); + if (mounted && _loadKey == key) { + setState(() => _browser.error = opdsErrorMessage(error)); + } } }); } @@ -81,6 +108,7 @@ class _CatalogsPageState extends State { Future _remove(OpdsCatalogs catalogs, OpdsCatalog catalog) async { final scope = catalogs.scope; ScaffoldMessenger.of(context).clearSnackBars(); + final confirmed = await showOpdsSheet( context, title: 'Remove catalog', @@ -92,17 +120,24 @@ class _CatalogsPageState extends State { 'Downloaded books will remain in your library.', ), ); - if (confirmed != true || !mounted || catalogs.scope != scope) return; + + if (confirmed != true || !mounted || catalogs.scope != scope) { + return; + } + try { await catalogs.remove(catalog.id); } catch (error) { - if (mounted) _message(opdsErrorMessage(error)); + if (mounted) { + _message(opdsErrorMessage(error)); + } } } void _navigate(OpdsCatalog catalog, Uri uri, {String query = ''}) { try { OpdsHttpClient.validateUri(uri); + context.push( Uri( path: '/library/catalogs/${Uri.encodeComponent(catalog.id)}', @@ -116,6 +151,7 @@ class _CatalogsPageState extends State { void _back(OpdsCatalog? catalog) { final router = GoRouter.of(context); + if (router.routerDelegate.currentConfiguration.last is ImperativeRouteMatch && router.canPop()) { router.pop(); } else if (catalog != null && widget.feedUri != null && widget.feedUri != catalog.uri) { @@ -128,17 +164,28 @@ class _CatalogsPageState extends State { } Future _submitSearch(OpdsCatalog catalog) async { - if (_searching || _search.text.trim().isEmpty) return; + if (_searching || _search.text.trim().isEmpty) { + return; + } + final key = _loadKey; final query = _search.text.trim(); setState(() => _searching = true); + try { final uri = await _browser.search(query); - if (mounted && key == _loadKey) _navigate(catalog, uri, query: query); + + if (mounted && key == _loadKey) { + _navigate(catalog, uri, query: query); + } } catch (error) { - if (mounted && key == _loadKey) _message(opdsErrorMessage(error)); + if (mounted && key == _loadKey) { + _message(opdsErrorMessage(error)); + } } finally { - if (mounted) setState(() => _searching = false); + if (mounted) { + setState(() => _searching = false); + } } } @@ -159,11 +206,13 @@ class _CatalogsPageState extends State { final catalogs = context.watch(); _scheduleLoad(catalogs); final catalog = widget.catalogId == null ? null : catalogs.find(widget.catalogId!); + return SafeArea( child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < Breakpoints.tablet; final mobileHome = widget.catalogId == null && MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + final body = Padding( padding: EdgeInsets.fromLTRB( compact ? Spacing.md : Spacing.lg, @@ -183,7 +232,10 @@ class _CatalogsPageState extends State { onChanged: (_) => setState(() {}), onClear: () { setState(() {}); - if (widget.query.isNotEmpty) _navigate(catalog, catalog.uri); + + if (widget.query.isNotEmpty) { + _navigate(catalog, catalog.uri); + } }, trailing: IconButton( tooltip: 'Search catalog', @@ -201,6 +253,7 @@ class _CatalogsPageState extends State { ], ), ); + final content = Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -208,7 +261,11 @@ class _CatalogsPageState extends State { Expanded(child: body), ], ); - if (!mobileHome) return content; + + if (!mobileHome) { + return content; + } + return Stack( fit: StackFit.expand, children: [ @@ -232,11 +289,13 @@ class _CatalogsPageState extends State { Widget _header(OpdsCatalogs catalogs, OpdsCatalog? catalog) { final mobile = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; final downloads = context.watch(); + final downloadsButton = OpdsDownloadsButton( compact: mobile, downloads: downloads, onRetry: (job) => unawaited(retryOpdsDownload(context, job)), ); + if (mobile) { if (widget.catalogId == null) { return Padding( @@ -247,6 +306,7 @@ class _CatalogsPageState extends State { ), ); } + return LibraryPageHeader( compact: true, key: const Key('catalog-mobile-header'), @@ -263,6 +323,7 @@ class _CatalogsPageState extends State { ], ); } + if (widget.catalogId == null) { return Padding( padding: const EdgeInsets.only(top: Spacing.lg, left: Spacing.lg, right: Spacing.lg), @@ -274,6 +335,7 @@ class _CatalogsPageState extends State { ), ); } + return LibraryPageHeader( title: catalog?.name ?? 'Catalogs', dividerKey: const Key('catalog-header-divider'), @@ -300,7 +362,11 @@ class _CatalogsPageState extends State { icon: Icons.cloud_off_outlined, ); } - if (catalogs.scope == null) return const Center(child: Text('Waiting for your library…')); + + if (catalogs.scope == null) { + return const Center(child: Text('Waiting for your library…')); + } + if (widget.catalogId != null) { return _empty( 'Catalog unavailable', @@ -309,6 +375,7 @@ class _CatalogsPageState extends State { onAction: () => context.go('/library/catalogs'), ); } + if (catalogs.catalogs.isEmpty) { return _empty( 'No catalogs yet', @@ -318,6 +385,7 @@ class _CatalogsPageState extends State { onAction: () => _edit(catalogs), ); } + return ListView.separated( key: ValueKey(catalogs.scope), padding: EdgeInsets.only(bottom: MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall ? 96 : Spacing.lg), @@ -325,6 +393,7 @@ class _CatalogsPageState extends State { separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (_, index) { final catalog = catalogs.catalogs[index]; + return CatalogSourceTile( key: ValueKey(catalog.id), catalog: catalog, @@ -340,6 +409,7 @@ class _CatalogsPageState extends State { Widget _feedView(OpdsCatalog catalog) { Widget? status; + if (_browser.error != null && _browser.feed == null) { status = _empty( 'Could not open this catalog', @@ -351,8 +421,13 @@ class _CatalogsPageState extends State { } else if (_browser.feed == null) { status = const Center(child: AppCircularProgressIndicator()); } + final feed = _browser.feed ?? _feedForHeader; - if (feed == null) return status ?? const SizedBox.shrink(); + + if (feed == null) { + return status ?? const SizedBox.shrink(); + } + return OpdsFeedView( libraryBookId: (publication) => context.read().libraryBookId(catalog, publication), status: _browser.feed != null && _browser.error != null diff --git a/app/lib/pages/dashboard_page.dart b/app/lib/pages/dashboard_page.dart index 55638868..5958022b 100644 --- a/app/lib/pages/dashboard_page.dart +++ b/app/lib/pages/dashboard_page.dart @@ -68,10 +68,6 @@ class _DashboardPageState extends State { ); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - /// Builds the mobile layout with vertically stacked cards. Widget _buildMobileLayout(BuildContext context, DashboardProvider provider) { return Scaffold( @@ -116,10 +112,6 @@ class _DashboardPageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - /// Builds the desktop layout with grid arrangement. Widget _buildDesktopLayout(BuildContext context, DashboardProvider provider) { return Scaffold( diff --git a/app/lib/pages/edit_profile_page.dart b/app/lib/pages/edit_profile_page.dart index 2595fbeb..8ff2fdc1 100644 --- a/app/lib/pages/edit_profile_page.dart +++ b/app/lib/pages/edit_profile_page.dart @@ -82,10 +82,6 @@ class _EditProfilePageState extends State { ); } - // =========================================================================== - // LAYOUT - // =========================================================================== - Widget _buildMobileBody(BuildContext context) { return SingleChildScrollView(padding: const EdgeInsets.all(Spacing.md), child: _buildForm(context)); } @@ -100,10 +96,6 @@ class _EditProfilePageState extends State { ); } - // =========================================================================== - // FORM - // =========================================================================== - Widget _buildForm(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -132,9 +124,11 @@ class _EditProfilePageState extends State { if (value == null || value.trim().isEmpty) { return 'Name cannot be empty'; } + if (value.trim().length > 100) { return 'Name is too long'; } + return null; }, autovalidateMode: AutovalidateMode.onUserInteraction, @@ -171,10 +165,6 @@ class _EditProfilePageState extends State { ); } - // =========================================================================== - // AVATAR - // =========================================================================== - Widget _buildAvatarEditor(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; const size = 120.0; @@ -235,6 +225,7 @@ class _EditProfilePageState extends State { // Show existing network photo if not removed. if (!_photoRemoved) { final photoUrl = context.read().user?.avatarUrl; + if (photoUrl != null && photoUrl.isNotEmpty) { return Image.network( photoUrl, @@ -250,8 +241,14 @@ class _EditProfilePageState extends State { } bool get _hasExistingPhoto { - if (_photoRemoved) return false; - if (_pickedImageBytes != null) return true; + if (_photoRemoved) { + return false; + } + + if (_pickedImageBytes != null) { + return true; + } + final photoUrl = context.read().user?.avatarUrl; return photoUrl != null && photoUrl.isNotEmpty; } @@ -287,6 +284,7 @@ class _EditProfilePageState extends State { title: Text('Remove photo', style: TextStyle(color: Theme.of(context).colorScheme.error)), onTap: () { Navigator.pop(sheetContext); + setState(() { _pickedImageBytes = null; _photoRemoved = true; @@ -302,13 +300,14 @@ class _EditProfilePageState extends State { Future _pickImage() async { try { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + if (result != null && result.files.single.bytes != null) { setState(() { _pickedImageBytes = result.files.single.bytes; _photoRemoved = false; }); } - } catch (e) { + } catch (error) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), @@ -318,12 +317,10 @@ class _EditProfilePageState extends State { } } - // =========================================================================== - // ACTIONS - // =========================================================================== - Future _handleSave(BuildContext context) async { - if (!_formKey.currentState!.validate()) return; + if (!_formKey.currentState!.validate()) { + return; + } setState(() { _isSaving = true; @@ -339,6 +336,7 @@ class _EditProfilePageState extends State { _errorMessage = 'Not signed in'; _isSaving = false; }); + return; } @@ -353,6 +351,7 @@ class _EditProfilePageState extends State { _errorMessage = authProvider.error ?? 'Failed to update profile'; _isSaving = false; }); + return; } } @@ -360,7 +359,7 @@ class _EditProfilePageState extends State { if (context.mounted) { context.pop(); } - } catch (e) { + } catch (error) { setState(() { _errorMessage = 'Failed to update profile'; _isSaving = false; @@ -393,23 +392,29 @@ class _EditProfilePageState extends State { } } - // =========================================================================== - // HELPERS - // =========================================================================== - String _getEmail() { final email = context.read().user?.email; - if (email == null || email.trim().isEmpty) return 'No email provided'; + + if (email == null || email.trim().isEmpty) { + return 'No email provided'; + } + return email; } String get _initials { final name = _nameController.text.trim(); - if (name.isEmpty) return '?'; + + if (name.isEmpty) { + return '?'; + } + final parts = name.split(' '); + if (parts.length >= 2) { return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); } + return name[0].toUpperCase(); } } diff --git a/app/lib/pages/forgot_password_page.dart b/app/lib/pages/forgot_password_page.dart index bfc7c8cc..ca0be016 100644 --- a/app/lib/pages/forgot_password_page.dart +++ b/app/lib/pages/forgot_password_page.dart @@ -49,12 +49,16 @@ class _ForgotPasswordPageState extends State { } Future _handleResetPassword() async { - if (_isLoading) return; + if (_isLoading) { + return; + } // Hide keyboard FocusScope.of(context).unfocus(); - if (!_formKey.currentState!.validate()) return; + if (!_formKey.currentState!.validate()) { + return; + } setState(() => _isLoading = true); ScaffoldMessenger.of(context).hideCurrentSnackBar(); @@ -62,7 +66,9 @@ class _ForgotPasswordPageState extends State { try { final message = await context.read().forgotPassword(_emailController.text.trim()); - if (!mounted) return; + if (!mounted) { + return; + } setState(() { _isLoading = false; @@ -72,18 +78,22 @@ class _ForgotPasswordPageState extends State { if (message != null) { _showSuccessSnackBar(message); } - } catch (e) { - if (!mounted) return; + } catch (error) { + if (!mounted) { + return; + } + setState(() => _isLoading = false); _showErrorSnackBar('An error occurred. Please try again.'); } } Future _handleSetNewPassword() async { - if (_isLoading) return; + if (_isLoading) { + return; + } FocusScope.of(context).unfocus(); - final resetToken = widget.resetToken; if (resetToken == null || resetToken.isEmpty) { @@ -91,7 +101,9 @@ class _ForgotPasswordPageState extends State { return; } - if (!_resetFormKey.currentState!.validate()) return; + if (!_resetFormKey.currentState!.validate()) { + return; + } setState(() => _isLoading = true); ScaffoldMessenger.of(context).hideCurrentSnackBar(); @@ -102,7 +114,9 @@ class _ForgotPasswordPageState extends State { password: _passwordController.text, ); - if (!mounted) return; + if (!mounted) { + return; + } if (message == null) { _showErrorSnackBar(context.read().error ?? 'Failed to reset password.'); @@ -114,9 +128,13 @@ class _ForgotPasswordPageState extends State { _isLoading = false; _passwordReset = true; }); + _showSuccessSnackBar(message); - } catch (e) { - if (!mounted) return; + } catch (error) { + if (!mounted) { + return; + } + setState(() => _isLoading = false); _showErrorSnackBar('An error occurred. Please try again.'); } @@ -191,6 +209,7 @@ class _ForgotPasswordPageState extends State { if (_emailSent) { return _EmailSentConfirmation(email: _emailController.text.trim()); } + return _ForgotPasswordForm( formKey: _formKey, emailController: _emailController, @@ -211,9 +230,11 @@ class _ForgotPasswordPageState extends State { @override Widget build(BuildContext context) { final heading = widget.isResetLink ? 'Create new password' : 'Reset password'; + final subtitle = widget.isResetLink ? 'Enter a new password for your account' : 'Enter your email to receive a password reset link'; + final showHeader = !_emailSent && !_passwordReset; return ResponsiveBuilder( @@ -235,10 +256,6 @@ class _ForgotPasswordPageState extends State { } } -// ============================================================================= -// FORGOT PASSWORD FORM -// ============================================================================= - /// Forgot password form with a single email field. class _ForgotPasswordForm extends StatelessWidget { final GlobalKey formKey; @@ -280,10 +297,6 @@ class _ForgotPasswordForm extends StatelessWidget { } } -// ============================================================================= -// EMAIL SENT CONFIRMATION -// ============================================================================= - /// Confirmation view shown after the reset email has been sent. class _EmailSentConfirmation extends StatelessWidget { final String email; diff --git a/app/lib/pages/goals_page.dart b/app/lib/pages/goals_page.dart index f7a4e54f..e0e01d3c 100644 --- a/app/lib/pages/goals_page.dart +++ b/app/lib/pages/goals_page.dart @@ -24,6 +24,7 @@ import 'package:papyrus/widgets/shared/app_progress_indicator.dart'; class GoalsPage extends StatefulWidget { const GoalsPage({super.key, this.now}); final DateTime Function()? now; + @override State createState() => _GoalsPageState(); } @@ -33,6 +34,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { bool _activity = false; bool _hideCompleted = false; late TabController _tabs; + @override void initState() { super.initState(); @@ -41,17 +43,21 @@ class _GoalsPageState extends State with TickerProviderStateMixin { } void _onTabChanged() { - if (_activity != (_tabs.index == 1)) setState(() => _activity = _tabs.index == 1); + if (_activity != (_tabs.index == 1)) { + setState(() => _activity = _tabs.index == 1); + } } String? _filterGoal; DateTimeRange? _filterDates; int? _activityYear; String _activityKind = 'all'; + @override void didChangeDependencies() { super.didChangeDependencies(); final duration = AppMotion.duration(context, kTabScrollDuration); + if (_tabs.animationDuration != duration) { final index = _tabs.index; _tabs.removeListener(_onTabChanged); @@ -59,6 +65,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { _tabs = TabController(length: 2, vsync: this, initialIndex: index, animationDuration: duration); _tabs.addListener(_onTabChanged); } + _provider.attach(context.read()); } @@ -86,6 +93,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { child: LayoutBuilder( builder: (context, constraints) { final padding = libraryPageHorizontalPadding(context); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -123,10 +131,12 @@ class _GoalsPageState extends State with TickerProviderStateMixin { ), ); } + 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( @@ -258,7 +268,9 @@ class _GoalsPageState extends State with TickerProviderStateMixin { ], ); } + final colors = Theme.of(context).colorScheme; + return Wrap( spacing: Spacing.sm, children: [ @@ -287,29 +299,43 @@ class _GoalsPageState extends State with TickerProviderStateMixin { 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'; + if (_hideCompleted && progress.reached) { + continue; + } + + final status = switch (!progress.projected.isActive) { + true => 'Paused', + false when progress.reached => 'Completed', + false when progress.fraction > 0 => 'In progress', + false => '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); + goals.sort((left, right) { + final progress = right.fraction.compareTo(left.fraction); + + if (progress != 0) { + return progress; + } + + final deadline = left.range.end.compareTo(right.range.end); + + if (deadline != 0) { + return deadline; + } + + final created = left.goal.createdAt.compareTo(right.goal.createdAt); + return created != 0 ? created : left.goal.id.compareTo(right.goal.id); }); } final texts = Theme.of(context).textTheme; + return [ for (final group in groups.entries) if (group.value.isNotEmpty) ...[ @@ -329,6 +355,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { }) => 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, @@ -343,6 +370,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { ), onMenu: historical ? null : (action) => _goalAction(progress.goal, action), ); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -366,6 +394,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { ); }, ); + String _scopeLabel(ReadingGoal goal) => switch (goal.scope) { GoalScope.library => 'Whole library', GoalScope.book => @@ -374,35 +403,48 @@ class _GoalsPageState extends State with TickerProviderStateMixin { : _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; + + if (!names.containsKey(_filterGoal)) { + _filterGoal = null; + } + final selected = _filterGoal == null ? null - : _provider.store.getReadingGoal(_filterGoal!) ?? periods.where((p) => p.goal.id == _filterGoal).first.goal; + : _provider.store.getReadingGoal(_filterGoal!) ?? + periods.where((item) => item.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)) + .where((activity) => selected == null || matchesGoal(selected, activity)) .toList(); + final corrected = _provider.store.readingActivities - .where((a) => a.kind == 'reversal') - .map((a) => a.correctionOf) + .where((activity) => activity.kind == 'reversal') + .map((activity) => activity.correctionOf) .whereType() .toSet(); + bool matchesKind(ReadingActivity a) => switch (_activityKind) { 'reader' => a.source == 'reader', 'manual' => a.source != 'reader', @@ -411,15 +453,22 @@ class _GoalsPageState extends State with TickerProviderStateMixin { _ => 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 effective = effectiveActivities( + scoped, + ).where((activity) => _activityKind != 'corrected' && matchesKind(activity)).toList(); + final visible = - (_activityKind == 'corrected' ? scoped.where((a) => a.kind != 'reversal' && matchesKind(a)) : effective) + (_activityKind == 'corrected' + ? scoped.where((activity) => activity.kind != 'reversal' && matchesKind(activity)) + : 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)), + (activity) => (activity.kind == 'reading' && activity.endTime.isAfter(activity.startTime) + ? activity.endTime.isAfter(range.start) && activity.startTime.isBefore(range.end) + : range.contains(activity.endTime)), ) .toList(); + final totals = projectGoal( ReadingGoal( id: 'activity-summary', @@ -436,22 +485,26 @@ class _GoalsPageState extends State with TickerProviderStateMixin { now, period: range, ); + 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), + (item) => + !archivedIds.contains(item.goal.id) && + (_filterGoal == null || item.goal.id == _filterGoal) && + item.range.start.isBefore(range.end) && + item.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, @@ -522,6 +575,7 @@ class _GoalsPageState extends State with TickerProviderStateMixin { lastDate: DateTime.now(), initialDateRange: _filterDates, ); + if (dates != null && mounted) { setState(() { _filterDates = dates; @@ -531,11 +585,12 @@ class _GoalsPageState extends State with TickerProviderStateMixin { }, 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)}', + switch (_filterDates == null) { + true => 'Filter dates', + false when _filterDates!.start == _filterDates!.end => DateFormat.MMMd().format(_filterDates!.start), + false => + '${DateFormat.MMMd().format(_filterDates!.start)} – ${DateFormat.MMMd().format(_filterDates!.end)}', + }, ), ), if (_filterDates != null) @@ -632,17 +687,25 @@ class _GoalsPageState extends State with TickerProviderStateMixin { await AddGoalSheet.show(context, provider: _provider, editing: _provider.store.getReadingGoal(goal.id)); return; } + final repository = _provider.store.trackingRepository; + try { if (action == 'delete') { if (await confirmGoalDeletion(context) && mounted) { await _provider.deleteGoal(goal.id, repository: repository); } + return; } - if (action == 'pause') await _provider.pauseGoal(goal.id, goal.isActive); + + if (action == 'pause') { + await _provider.pauseGoal(goal.id, goal.isActive); + } + if (action == 'archive') { await _provider.archiveGoal(goal.id); + if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -653,7 +716,9 @@ class _GoalsPageState extends State with TickerProviderStateMixin { } } } catch (error) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString()))); + } } } diff --git a/app/lib/pages/library_page.dart b/app/lib/pages/library_page.dart index 1f135b92..90d22e54 100644 --- a/app/lib/pages/library_page.dart +++ b/app/lib/pages/library_page.dart @@ -80,7 +80,6 @@ class _LibraryPageState extends State { void didChangeDependencies() { super.didChangeDependencies(); final provider = widget.isShelfView ? null : context.read(); - _updateVisibleDownloadsProvider(provider); } @@ -168,10 +167,6 @@ class _LibraryPageState extends State { return provider.sortBooks(books); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout( BuildContext context, List books, @@ -183,11 +178,14 @@ class _LibraryPageState extends State { ) { final isOnline = !widget.isShelfView && _presentationMode == _BooksPresentationMode.online && downloadsProvider != null; + final isBookSelection = libraryProvider.isSelectionMode; + final localItems = buildAcquisitionLibraryItems( books: sourceBooks, jobs: widget.isShelfView ? const [] : downloadsProvider?.jobs ?? const [], ); + final showDownloadingOnly = _showDownloadingOnly && localItems.hasDownloadingItems; final acquisitionView = _buildAcquisitionLibraryView( @@ -216,23 +214,22 @@ class _LibraryPageState extends State { left: libraryPageHorizontalPadding(context), right: libraryPageHorizontalPadding(context), ), - child: isOnline - ? _buildOnlineHeader(downloadsProvider) - : hasJobSelection - ? _buildJobSelectionHeader( - downloadsProvider!, - selectableJobs: acquisitionView.selectableJobs, - includeActions: false, - ) - : isBookSelection - ? SelectionHeader( - selectedCount: libraryProvider.selectedCount, - totalCount: books.length, - onClose: libraryProvider.exitSelectionMode, - onSelectAll: () => libraryProvider.selectAll(books.map((b) => b.id).toList()), - onDeselectAll: libraryProvider.deselectAll, - ) - : _buildMobileLocalHeader(libraryProvider), + child: switch (downloadsProvider) { + final provider? when isOnline => _buildOnlineHeader(provider), + _ when hasJobSelection => _buildJobSelectionHeader( + downloadsProvider!, + selectableJobs: acquisitionView.selectableJobs, + includeActions: false, + ), + _ when isBookSelection => SelectionHeader( + selectedCount: libraryProvider.selectedCount, + totalCount: books.length, + onClose: libraryProvider.exitSelectionMode, + onSelectAll: () => libraryProvider.selectAll(books.map((book) => book.id).toList()), + onDeselectAll: libraryProvider.deselectAll, + ), + _ => _buildMobileLocalHeader(libraryProvider), + }, ), if (!isOnline) @@ -273,13 +270,14 @@ class _LibraryPageState extends State { tooltip: widget.isShelfView ? 'Add to shelf' : null, child: const Icon(Icons.add), ), - bottomNavigationBar: isOnline && downloadsProvider.selectedReleaseTokens.isNotEmpty - ? _buildMobileOnlineAction(downloadsProvider) - : hasJobSelection - ? _buildMobileJobActions(downloadsProvider!, selectedJobs) - : isBookSelection - ? buildMobileBottomActionBar(context, libraryProvider) - : null, + bottomNavigationBar: switch (downloadsProvider) { + final provider? when isOnline && provider.selectedReleaseTokens.isNotEmpty => _buildMobileOnlineAction( + provider, + ), + _ when hasJobSelection => _buildMobileJobActions(downloadsProvider!, selectedJobs), + _ when isBookSelection => buildMobileBottomActionBar(context, libraryProvider), + _ => null, + }, ); } @@ -320,6 +318,7 @@ class _LibraryPageState extends State { final libraryProvider = context.read(); final dataStore = context.read(); final sourceBooks = _sourceBooks(dataStore); + final filters = await LibraryAdvancedFilterSheet.show( context, libraryProvider: libraryProvider, @@ -489,6 +488,7 @@ class _LibraryPageState extends State { Widget _buildJobActions(AcquisitionDownloadsProvider provider, List selectedJobs) { final canCancel = selectedJobs.isNotEmpty && selectedJobs.every((job) => job.canCancel); final canRetry = selectedJobs.isNotEmpty && selectedJobs.every((job) => job.canRetryImport); + final canRemove = selectedJobs.isNotEmpty && selectedJobs.every( @@ -529,6 +529,7 @@ class _LibraryPageState extends State { Future _cancelSelectedJobs(AcquisitionDownloadsProvider provider, List selectedJobs) async { final presentationGeneration = _presentationGeneration; + final confirmed = await showAcquisitionConfirmationDialog( context: context, title: 'Cancel downloads', @@ -551,6 +552,7 @@ class _LibraryPageState extends State { Future _removeSelectedJobs(AcquisitionDownloadsProvider provider, List selectedJobs) async { final presentationGeneration = _presentationGeneration; + final confirmed = await showAcquisitionConfirmationDialog( context: context, title: 'Remove downloads', @@ -587,10 +589,6 @@ class _LibraryPageState extends State { ..showSnackBar(snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text(message))); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout( BuildContext context, List books, @@ -602,18 +600,23 @@ class _LibraryPageState extends State { ) { final isOnline = !widget.isShelfView && _presentationMode == _BooksPresentationMode.online && downloadsProvider != null; + final isBookSelection = libraryProvider.isSelectionMode; + final localItems = buildAcquisitionLibraryItems( books: sourceBooks, jobs: widget.isShelfView ? const [] : downloadsProvider?.jobs ?? const [], ); + final showDownloadingOnly = _showDownloadingOnly && localItems.hasDownloadingItems; + final acquisitionView = _buildAcquisitionLibraryView( books: books, items: localItems, libraryProvider: libraryProvider, showDownloadingOnly: showDownloadingOnly, ); + final selectedJobs = _visibleSelectedJobs(downloadsProvider, acquisitionView.selectableJobs); final hasJobSelection = selectedJobs.isNotEmpty; _pruneHiddenJobSelection(downloadsProvider, acquisitionView.selectableJobs); @@ -647,24 +650,23 @@ class _LibraryPageState extends State { left: libraryPageHorizontalPadding(context), right: libraryPageHorizontalPadding(context), ), - child: isOnline - ? _buildOnlineHeader(downloadsProvider) - : hasJobSelection - ? _buildJobSelectionHeader( - downloadsProvider!, - selectableJobs: acquisitionView.selectableJobs, - includeActions: true, - ) - : isBookSelection - ? SelectionHeader( - selectedCount: libraryProvider.selectedCount, - totalCount: books.length, - onClose: libraryProvider.exitSelectionMode, - onSelectAll: () => libraryProvider.selectAll(books.map((b) => b.id).toList()), - onDeselectAll: libraryProvider.deselectAll, - actions: buildBulkActionBar(context, libraryProvider), - ) - : _buildDesktopLocalHeader(libraryProvider, downloadsProvider), + child: switch (downloadsProvider) { + final provider? when isOnline => _buildOnlineHeader(provider), + _ when hasJobSelection => _buildJobSelectionHeader( + downloadsProvider!, + selectableJobs: acquisitionView.selectableJobs, + includeActions: true, + ), + _ when isBookSelection => SelectionHeader( + selectedCount: libraryProvider.selectedCount, + totalCount: books.length, + onClose: libraryProvider.exitSelectionMode, + onSelectAll: () => libraryProvider.selectAll(books.map((book) => book.id).toList()), + onDeselectAll: libraryProvider.deselectAll, + actions: buildBulkActionBar(context, libraryProvider), + ), + _ => _buildDesktopLocalHeader(libraryProvider, downloadsProvider), + }, ), if (!isOnline) Column( @@ -713,6 +715,7 @@ class _LibraryPageState extends State { ), ], ); + if (!widget.isShelfView) { return toolbar; } @@ -854,6 +857,7 @@ class _LibraryPageState extends State { } final horizontalPadding = libraryPageHorizontalPadding(context); + return BookGrid( // Match the list row's vertical inset without adding padding to list mode. padding: EdgeInsets.only(top: Spacing.sm, left: horizontalPadding, right: horizontalPadding, bottom: Spacing.md), @@ -877,11 +881,14 @@ class _LibraryPageState extends State { final visibleBooks = showDownloadingOnly ? books.where((book) => items.downloadingBookIds.contains(book.id)).toList() : books; + final candidatePlaceholders = showDownloadingOnly ? items.downloadingOrphanJobs : items.orphanJobs; final normalizedQuery = libraryProvider.searchQuery.trim().toLowerCase(); + final visiblePlaceholderJobs = normalizedQuery.isEmpty ? candidatePlaceholders : candidatePlaceholders.where((job) => job.title.toLowerCase().contains(normalizedQuery)).toList(); + final selectableJobs = []; final seenJobIds = {}; @@ -987,6 +994,7 @@ class _LibraryPageState extends State { final book = books[index]; final acquisitionJob = linkedJobsByBookId[book.id]; final isFavorite = libraryProvider.isBookFavorite(book.id, book.isFavorite); + if (acquisitionJob == null) { unawaited(storageStatusController?.ensureDeviceStatus(book)); } diff --git a/app/lib/pages/notes_page.dart b/app/lib/pages/notes_page.dart index f9889706..f528ce93 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -64,16 +64,13 @@ class _NotesPageState extends State { if (isDesktop) { return _buildDesktopLayout(context, provider); } + return _buildMobileLayout(context, provider); }, ), ); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout(BuildContext context, NotesProvider provider) { return Scaffold( key: _scaffoldKey, @@ -109,10 +106,6 @@ class _NotesPageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout(BuildContext context, NotesProvider provider) { return Scaffold( body: Column( @@ -139,10 +132,6 @@ class _NotesPageState extends State { ); } - // ============================================================================ - // SHARED WIDGETS - // ============================================================================ - Widget _buildSearchField(NotesProvider provider, {Widget? leading}) { return TextField( controller: _searchController, @@ -272,6 +261,7 @@ class _NotesPageState extends State { for (final entry in groups.entries) { final isCollapsed = _collapsedGroups.contains(entry.key); + items.add( BookGroupHeader( bookId: entry.key, @@ -292,6 +282,7 @@ class _NotesPageState extends State { }, ), ); + if (!isCollapsed) { for (final note in entry.value) { items.add( @@ -311,10 +302,6 @@ class _NotesPageState extends State { ); } - // ============================================================================ - // ACTIONS - // ============================================================================ - void _navigateToBook(BuildContext context, String bookId) { context.goNamed('BOOK_DETAILS', pathParameters: {'bookId': bookId}); } @@ -322,7 +309,10 @@ class _NotesPageState extends State { void _showNoteActions(BuildContext context, NotesProvider provider, Note note) async { final repository = context.read().libraryRepository?.notes; final action = await NoteActionSheet.show(context, note: note); - if (!mounted || action == null) return; + + if (!mounted || action == null) { + return; + } switch (action) { case NoteAction.edit: @@ -344,6 +334,7 @@ class _NotesPageState extends State { const SnackBar(content: Text('Could not delete. Please try again.')), ); } + return; } } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 0f3a38a2..187f13ff 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -52,14 +52,13 @@ class _ProfilePageState extends State { final screenWidth = MediaQuery.of(context).size.width; final isDesktop = screenWidth >= Breakpoints.desktopSmall; - if (isDesktop) return _buildDesktopLayout(context); + if (isDesktop) { + return _buildDesktopLayout(context); + } + return _buildMobileLayout(context); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Profile')), @@ -110,10 +109,6 @@ class _ProfilePageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout(BuildContext context) { return Scaffold( body: SafeArea( @@ -229,16 +224,18 @@ class _ProfilePageState extends State { final textTheme = Theme.of(context).textTheme; final isSelected = section != null && _selectedSection == section; - final iconColor = isDestructive - ? colorScheme.error - : isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant; - final textColor = isDestructive - ? colorScheme.error - : isSelected - ? colorScheme.onPrimaryContainer - : null; + final iconColor = switch (isDestructive) { + true => colorScheme.error, + false when isSelected => colorScheme.onPrimaryContainer, + false => colorScheme.onSurfaceVariant, + }; + + final textColor = switch (isDestructive) { + true => colorScheme.error, + false when isSelected => colorScheme.onPrimaryContainer, + false => null, + }; + final bgColor = isSelected ? colorScheme.primaryContainer : Colors.transparent; return Padding( @@ -278,10 +275,6 @@ class _ProfilePageState extends State { ); } - // ============================================================================ - // DESKTOP CONTENT PANEL - // ============================================================================ - Widget _buildDesktopContent(BuildContext context) { final textTheme = Theme.of(context).textTheme; @@ -354,14 +347,13 @@ class _ProfilePageState extends State { } } - // ============================================================================ - // LOGOUT - // ============================================================================ - Future _handleLogout(BuildContext context) async { final authProvider = Provider.of(context, listen: false); await authProvider.signOut(); - if (context.mounted) context.go('/login'); + + if (context.mounted) { + context.go('/login'); + } } void _showLogoutConfirmation(BuildContext context) { diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index f90bb11f..6e6c93d2 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -46,13 +46,16 @@ class _ReaderPageState extends State { @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; @@ -64,7 +67,10 @@ class _ReaderPageState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - if (_startedLoading) return; + + if (_startedLoading) { + return; + } _startedLoading = true; _load(); @@ -75,13 +81,16 @@ class _ReaderPageState extends State { final repository = dataStore.requireBookRepository(); final trackingRepository = dataStore.trackingRepository; var book = dataStore.getBook(widget.bookId); - book ??= await repository.getById(widget.bookId); + if (book == null && !dataStore.isLoaded) { await dataStore.waitUntilLoaded(); book = dataStore.getBook(widget.bookId); } - if (!mounted) return; + + if (!mounted) { + return; + } if (book == null) { setState(() => _error = 'Book not found.'); @@ -89,48 +98,69 @@ class _ReaderPageState extends State { } final format = ReaderBookAdapter.formatFor(book.fileFormat); + if (format == null) { setState(() { _book = book; _error = 'This book format is not supported yet.'; }); + return; } try { final importService = context.read(); + final bytes = await context.read().ensureBookFileCached( book, readLocalBookFile: importService.getBookFile, writeLocalBookFile: importService.storeBookFile, downloadMedia: context.read().downloadMedia, ); - if (!mounted) return; - if (!dataStore.isBookRepositoryCurrent(repository)) return; + if (!mounted) { + return; + } + + if (!dataStore.isBookRepositoryCurrent(repository)) { + return; + } + ReadingActivityTracker? tracker; + if (trackingRepository != null) { - if (!mounted || !trackingRepository.isCurrent) return; + 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; + 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, @@ -144,6 +174,7 @@ class _ReaderPageState extends State { setState(() { _book = book; + _document = ReaderDocument( id: book!.id, format: format, @@ -151,12 +182,16 @@ class _ReaderPageState extends State { author: book.author, loadBytes: () async => bytes, ); + _initialLocator = ReaderBookAdapter.restoreLocator(book); _initialPreferences = preferences; _session = session; }); } catch (_) { - if (!mounted) return; + if (!mounted) { + return; + } + setState(() => _error = 'Could not open this book file.'); } } @@ -175,6 +210,7 @@ class _ReaderPageState extends State { @override Widget build(BuildContext context) { final error = _error; + if (error != null) { return Scaffold( appBar: AppBar(leading: const BackButton()), @@ -190,6 +226,7 @@ class _ReaderPageState extends State { final book = _book; final document = _document; final preferences = _initialPreferences; + if (book == null || document == null || preferences == null) { return Scaffold( appBar: AppBar(leading: BackButton(onPressed: _close)), @@ -212,12 +249,18 @@ class _ReaderPageState extends State { void _onActivity(ReaderActivityEvent event) { _tracker?.onActivity(event); + if (!event.atEnd || !event.visible || !event.ready) { _finishTimer?.cancel(); _finishTimer = null; - if (!event.atEnd) _finishPrompted = false; + + if (!event.atEnd) { + _finishPrompted = false; + } + return; } + if (_foreground && !_finishPrompted && _finishTimer == null && @@ -228,10 +271,15 @@ class _ReaderPageState extends State { Future _confirmFinished() async { _finishTimer = null; - if (!mounted || !_foreground || _tracker?.repository.isCurrent == false) return; + + if (!mounted || !_foreground || _tracker?.repository.isCurrent == false) { + return; + } + _finishPrompted = true; _promptOpen = true; _tracker?.setForeground(false); + final finished = await showDialog( context: context, builder: (context) => AlertDialog( @@ -243,11 +291,16 @@ class _ReaderPageState extends State { ], ), ); - if (!mounted) return; + + 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()), @@ -271,6 +324,7 @@ class _ReaderPageState extends State { Future _close() async { _finishTimer?.cancel(); + try { await _tracker?.close(); await _session?.flush(); @@ -280,9 +334,14 @@ class _ReaderPageState extends State { context, )?.showSnackBar(const SnackBar(content: Text('Could not save reading activity. Please try leaving again.'))); } + + return; + } + + if (!mounted) { return; } - if (!mounted) return; + if (context.canPop()) { context.pop(); return; diff --git a/app/lib/pages/shelf_contents_page.dart b/app/lib/pages/shelf_contents_page.dart index 5c79c54a..18ee450e 100644 --- a/app/lib/pages/shelf_contents_page.dart +++ b/app/lib/pages/shelf_contents_page.dart @@ -57,6 +57,7 @@ class ShelfContentsPage extends StatelessWidget { void _editShelf(BuildContext context, DataStore dataStore, Shelf shelf) { final repository = dataStore.libraryRepository?.shelves; + AddShelfSheet.show( context, shelf: shelf, diff --git a/app/lib/pages/shelves_page.dart b/app/lib/pages/shelves_page.dart index 1f7745cb..808d051c 100644 --- a/app/lib/pages/shelves_page.dart +++ b/app/lib/pages/shelves_page.dart @@ -79,18 +79,10 @@ class _ShelvesPageState extends State { ); } - // ============================================================================ - // LOADING STATE - // ============================================================================ - Widget _buildLoadingState(BuildContext context) { return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout(BuildContext context, ShelvesProvider provider) { final shelves = provider.shelves; @@ -126,10 +118,6 @@ class _ShelvesPageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout(BuildContext context, ShelvesProvider provider) { final shelves = provider.shelves; @@ -158,10 +146,6 @@ class _ShelvesPageState extends State { ); } - // ============================================================================ - // HEADER CONTROLS - // ============================================================================ - Widget _buildSearchField(ShelvesProvider provider, {Widget? leading}) { return TextField( controller: _searchController, @@ -186,20 +170,19 @@ class _ShelvesPageState extends State { ); } - // ============================================================================ - // SHARED WIDGETS - // ============================================================================ - Widget _buildShelfResults(BuildContext context, ShelvesProvider provider, List shelves) { if (!provider.hasAnyShelves) { return _buildEmptyState(context); } + if (shelves.isEmpty) { return _buildNoResultsState(context); } + if (provider.viewMode == LibraryViewMode.list) { return _buildShelfList(context, shelves); } + return _buildShelfGrid(context, shelves, provider.gridItemWidth); } @@ -224,6 +207,7 @@ class _ShelvesPageState extends State { itemCount: shelves.length, itemBuilder: (context, index) { final shelf = shelves[index]; + return ShelfCard( shelf: shelf, onTap: () => _showShelfDetail(context, shelf), @@ -242,6 +226,7 @@ class _ShelvesPageState extends State { itemCount: shelves.length, itemBuilder: (context, index) { final shelf = shelves[index]; + return ShelfCard( shelf: shelf, isListItem: true, @@ -270,12 +255,9 @@ class _ShelvesPageState extends State { ); } - // ============================================================================ - // ACTIONS - // ============================================================================ - void _showAddShelfSheet(BuildContext context) { final repository = context.read().libraryRepository?.shelves; + AddShelfSheet.show( context, onSave: (name, description, colorHex, icon) async { @@ -292,6 +274,7 @@ class _ShelvesPageState extends State { void _showEditShelfSheet(BuildContext context, ShelfData shelf) { final repository = context.read().libraryRepository?.shelves; + AddShelfSheet.show( context, shelf: shelf, @@ -374,7 +357,10 @@ class _ShelvesPageState extends State { onPressed: () async { try { await _provider.deleteShelf(shelf.id, repository: repository); - if (context.mounted) Navigator.of(context).pop(); + + if (context.mounted) { + Navigator.of(context).pop(); + } } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/app/lib/pages/statistics_page.dart b/app/lib/pages/statistics_page.dart index f4debe1b..ee49112e 100644 --- a/app/lib/pages/statistics_page.dart +++ b/app/lib/pages/statistics_page.dart @@ -67,18 +67,10 @@ class _StatisticsPageState extends State { ); } - // ============================================================================ - // LOADING STATE - // ============================================================================ - Widget _buildLoadingState(BuildContext context) { return const Scaffold(body: Center(child: AppCircularProgressIndicator())); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - Widget _buildMobileLayout(BuildContext context, StatisticsProvider provider) { return Scaffold( body: SafeArea( @@ -139,8 +131,7 @@ class _StatisticsPageState extends State { Widget _buildPeriodSegmentedButton(BuildContext context, StatisticsProvider provider) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - - final periods = StatsPeriod.values.where((p) => p != StatsPeriod.custom).toList(); + final periods = StatsPeriod.values.where((item) => item != StatsPeriod.custom).toList(); final selectedPeriod = provider.selectedPeriod == StatsPeriod.custom ? StatsPeriod.week : provider.selectedPeriod; return Row( @@ -422,10 +413,6 @@ class _StatisticsPageState extends State { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - Widget _buildDesktopLayout(BuildContext context, StatisticsProvider provider) { return Scaffold( body: SafeArea( @@ -533,10 +520,6 @@ class _StatisticsPageState extends State { return _buildGenreBarsCard(context, provider, isDesktop: true); } - // ============================================================================ - // HELPERS - // ============================================================================ - String _getPeriodLabel(StatsPeriod period) { switch (period) { case StatsPeriod.week: @@ -554,6 +537,7 @@ class _StatisticsPageState extends State { Future _showDateRangePicker(BuildContext context, StatisticsProvider provider) async { final now = DateTime.now(); + final initialRange = provider.hasCustomRange ? DateTimeRange(start: provider.customStartDate!, end: provider.customEndDate!) : DateTimeRange(start: now.subtract(const Duration(days: 30)), end: now); diff --git a/app/lib/platform/book_import_drop_registration_web.dart b/app/lib/platform/book_import_drop_registration_web.dart index 59960562..7f1b1cfc 100644 --- a/app/lib/platform/book_import_drop_registration_web.dart +++ b/app/lib/platform/book_import_drop_registration_web.dart @@ -4,7 +4,10 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart'; bool _isRegistered = false; void ensureBookImportDropPluginRegistered() { - if (_isRegistered) return; + if (_isRegistered) { + return; + } + DesktopDropWeb.registerWith(webPluginRegistrar); webPluginRegistrar.registerMessageHandler(); _isRegistered = true; diff --git a/app/lib/powersync/library_database.dart b/app/lib/powersync/library_database.dart index f92b7ecc..ec9e0869 100644 --- a/app/lib/powersync/library_database.dart +++ b/app/lib/powersync/library_database.dart @@ -20,6 +20,7 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { int trackingSchemaVersion = 0; bool get trackingSupported => trackingSchemaVersion > 0; set trackingSupported(bool value) => trackingSchemaVersion = value ? 2 : 0; + @override bool get isCurrent => active; @@ -35,16 +36,22 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { late final books = ScopedBooks(this); void checkActive() { - if (!active) throw StateError('The library changed. Reopen this item before saving.'); + if (!active) { + throw StateError('The library changed. Reopen this item before saving.'); + } } Future write(Future Function(SqliteWriteContext) action) async { checkActive(); + await database.writeTransaction((tx) async { checkActive(); await action(tx); }); - if (active) await onWrite(); + + if (active) { + await onWrite(); + } } Future upsert(String table, Map row, {Map? previous}) => @@ -58,17 +65,24 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { }) async { final id = row['id']; final existing = await tx.getOptional('SELECT * FROM $table WHERE id = ?', [id]); + if (existing == null) { - if (previous != null) return; + if (previous != null) { + return; + } + await _validateReferences(tx, table, row); + await tx.execute( 'INSERT INTO $table (${row.keys.join(', ')}) VALUES (${List.filled(row.length, '?').join(', ')})', row.values.toList(), ); + return; } final baseline = previous ?? Map.from(existing); + final changes = Map.fromEntries( row.entries.where( (entry) => @@ -76,9 +90,17 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { !_sameValue(entry.key, entry.value, baseline[entry.key]), ), ); - if (changes.isEmpty) return; - if (row.containsKey('updated_at')) changes['updated_at'] = DateTime.now().toUtc().toIso8601String(); + + if (changes.isEmpty) { + return; + } + + if (row.containsKey('updated_at')) { + changes['updated_at'] = DateTime.now().toUtc().toIso8601String(); + } + await _validateReferences(tx, table, {...existing, ...changes}); + await tx.execute('UPDATE $table SET ${changes.keys.map((key) => '$key = ?').join(', ')} WHERE id = ?', [ ...changes.values, id, @@ -88,17 +110,30 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { Future _validateReferences(SqliteReadContext tx, String table, Map row) async { for (final reference in {'book_id': 'books', 'shelf_id': 'shelves', 'tag_id': 'tags'}.entries) { final id = row[reference.key]; + if (id != null && await tx.getOptional('SELECT id FROM ${reference.value} WHERE id = ?', [id]) == null) { throw StateError('The referenced ${reference.value} record no longer exists.'); } } - if (table != 'shelves') return; + + if (table != 'shelves') { + return; + } + final visited = {row['id']}; var parent = row['parent_shelf_id']; + while (parent != null) { - if (!visited.add(parent)) throw StateError('A shelf cannot contain itself.'); + if (!visited.add(parent)) { + throw StateError('A shelf cannot contain itself.'); + } + final ancestor = await tx.getOptional('SELECT parent_shelf_id FROM shelves WHERE id = ?', [parent]); - if (ancestor == null) throw StateError('The parent shelf no longer exists.'); + + if (ancestor == null) { + throw StateError('The parent shelf no longer exists.'); + } + parent = ancestor['parent_shelf_id']; } } @@ -110,6 +145,7 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { } } else if (table == 'shelves') { await tx.execute('DELETE FROM book_shelves WHERE shelf_id = ?', [id]); + await tx.execute('UPDATE shelves SET parent_shelf_id = NULL, updated_at = ? WHERE parent_shelf_id = ?', [ DateTime.now().toUtc().toIso8601String(), id, @@ -117,6 +153,7 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { } else if (table == 'tags') { await tx.execute('DELETE FROM book_tags WHERE tag_id = ?', [id]); } + await tx.execute('DELETE FROM $table WHERE id = ?', [id]); }); @@ -130,18 +167,25 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { bool additive = false, }) => write((tx) async { Future update(String table, String field, List? selected, Set? baseline) async { - if (selected == null) return; + if (selected == null) { + return; + } + final wanted = selected.toSet(); + for (final bookId in bookIds) { final rows = await tx.getAll('SELECT $field FROM $table WHERE book_id = ?', [bookId]); final current = rows.map((row) => row[field] as String).toSet(); final original = baseline ?? current; + if (!additive) { for (final removed in original.difference(wanted)) { await tx.execute('DELETE FROM $table WHERE id = ?', ['$bookId:$removed']); } } + final additions = additive ? wanted : wanted.difference(original); + for (final added in additions.difference(current)) { await upsertRow(tx, table, { 'id': '$bookId:$added', @@ -162,11 +206,13 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { 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], @@ -177,6 +223,7 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { rows[staged['row_id'] as String] = jsonDecode(staged['payload'] as String) as Map; } } + return rows.values.map((row) => decode(Map.from(row))).toList(); } @@ -199,19 +246,26 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { 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 (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']]); } } @@ -230,22 +284,28 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { 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), @@ -273,50 +333,84 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { 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'; + + 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); + + 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 { - if (await tx.getOptional("SELECT id FROM library_migrations WHERE id = 'book-fields-v1'") != null) return; + if (await tx.getOptional("SELECT id FROM library_migrations WHERE id = 'book-fields-v1'") != null) { + return; + } + final storageTable = database.schema.tables.singleWhere((table) => table.name == 'books').internalName; + for (final raw in await tx.getAll('SELECT id, data FROM $storageTable')) { final row = Map.from(jsonDecode(raw['data'] as String) as Map); final encoded = row['custom_metadata']; - if (encoded is! String) continue; + + if (encoded is! String) { + continue; + } + final metadata = jsonDecode(encoded); - if (metadata is! Map) continue; + + if (metadata is! Map) { + continue; + } + final changes = {}; + for (final entry in metadata.entries) { - if (row.containsKey(entry.key)) continue; + if (row.containsKey(entry.key)) { + continue; + } + final value = _legacyPromotedValue(entry.key, entry.value); - if (value != null) changes[entry.key] = value; + + if (value != null) { + changes[entry.key] = value; + } } + if (changes.isNotEmpty) { await tx.execute('UPDATE books SET ${changes.keys.map((key) => '$key = ?').join(', ')} WHERE id = ?', [ ...changes.values, @@ -324,36 +418,61 @@ class LibraryDatabase implements LibraryMembershipWriter, TrackingRepository { ]); } } + await tx.execute("INSERT INTO library_migrations (id, version) VALUES ('book-fields-v1', 1)"); }); } } Object? _legacyPromotedValue(String key, Object? value) { - if (value == null) return null; + if (value == null) { + return null; + } + if (['publication_date', 'lent_at', 'started_at', 'completed_at', 'last_read_at'].contains(key)) { final date = value is String ? DateTime.tryParse(value)?.toUtc() : null; return date != null && date.year >= 1 && date.year <= 9999 ? date.toIso8601String() : null; } + if (['file_format', 'file_hash', 'physical_location', 'lent_to', 'series_id', 'series_name'].contains(key)) { return value is String ? value : null; } - if (key == 'file_size') return value is int && value >= 0 && value.bitLength <= 63 ? value : null; - if (key == 'series_number') return value is num && value.isFinite ? value.toDouble() : null; + + if (key == 'file_size') { + return value is int && value >= 0 && value.bitLength <= 63 ? value : null; + } + + if (key == 'series_number') { + return value is num && value.isFinite ? value.toDouble() : null; + } + if (key == 'is_physical') { - if (value is bool) return value ? 1 : 0; - if (value == 0 || value == 1) return value; + if (value is bool) { + return value ? 1 : 0; + } + + if (value == 0 || value == 1) { + return value; + } } + return null; } bool _sameValue(String key, Object? a, Object? b) { - if (a == b) return true; + if (a == b) { + return true; + } + if ((key.endsWith('_at') || key == 'publication_date') && a is String && b is String) { final first = DateTime.tryParse(a); final second = DateTime.tryParse(b); - if (first != null && second != null) return first.isAtSameMomentAs(second); + + if (first != null && second != null) { + return first.isAtSameMomentAs(second); + } } + return false; } @@ -380,8 +499,10 @@ class SqlEntityRepository implements EntityRepository { class ScopedBooks implements EditableBookRepository { final LibraryDatabase library; ScopedBooks(this.library); + @override bool get isCurrent => library.active; + @override Future getById(String id) async { library.checkActive(); @@ -393,11 +514,14 @@ class ScopedBooks implements EditableBookRepository { Stream> watchAll() => library.database .watch('SELECT * FROM books ORDER BY added_at DESC') .map((rows) => rows.map((row) => PowerSyncBookMapper.fromRow(Map.from(row))).toList()); + @override Future upsert(Book book) => library.upsert('books', PowerSyncBookMapper.toRow(book)); + @override Future update(Book book, {required Book previous}) => library.upsert('books', PowerSyncBookMapper.toRow(book), previous: PowerSyncBookMapper.toRow(previous)); + @override Future delete(String id) => library.delete('books', id); } diff --git a/app/lib/powersync/library_row_mapper.dart b/app/lib/powersync/library_row_mapper.dart index 3aed29ee..3855617f 100644 --- a/app/lib/powersync/library_row_mapper.dart +++ b/app/lib/powersync/library_row_mapper.dart @@ -50,9 +50,11 @@ final tagRowMapper = LibraryRowMapper( Map _locatedRow(Map json, BookLocation? location) { final row = Map.from(json); + for (final key in ['chapter', 'chapter_title', 'page_number', 'percentage']) { row.remove(key); } + row['location'] = location == null ? null : { @@ -61,13 +63,18 @@ Map _locatedRow(Map json, BookLocation? locati 'page_number': location.pageNumber, 'percentage': location.percentage, }; + return encodeLibraryRow(row); } Map _locatedJson(Map row) { final json = decodeLibraryRow(row); final location = json.remove('location'); - if (location is Map) json.addAll(Map.from(location)); + + if (location is Map) { + json.addAll(Map.from(location)); + } + return json; } @@ -93,8 +100,14 @@ final bookTagRowMapper = LibraryRowMapper( ); Map encodeLibraryRow(Map row) => row.map((key, value) { - if (value is bool) return MapEntry(key, value ? 1 : 0); - if (value is List || value is Map) return MapEntry(key, jsonEncode(value)); + if (value is bool) { + return MapEntry(key, value ? 1 : 0); + } + + if (value is List || value is Map) { + return MapEntry(key, jsonEncode(value)); + } + return MapEntry(key, value); }); @@ -102,8 +115,10 @@ 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', '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 69033cbc..4c9f8157 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -52,16 +52,22 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { 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 (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], @@ -69,6 +75,7 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { } }); } + final batch = powerSyncUploadBatchFromCrud(transaction.crud.where((entry) => !deferred.contains(entry)).toList()); if (batch.isEmpty) { diff --git a/app/lib/powersync/powersync_book_mapper.dart b/app/lib/powersync/powersync_book_mapper.dart index 5628de39..f5cfb89e 100644 --- a/app/lib/powersync/powersync_book_mapper.dart +++ b/app/lib/powersync/powersync_book_mapper.dart @@ -138,12 +138,15 @@ class PowerSyncBookMapper { } final decoded = Map.from(data); + if (decoded.containsKey('co_authors') && decoded['co_authors'] != null) { decoded['co_authors'] = _decodeStringList(decoded['co_authors']); } + if (decoded.containsKey('custom_metadata') && decoded['custom_metadata'] != null) { decoded['custom_metadata'] = _decodeObject(decoded['custom_metadata']); } + return decoded; } @@ -172,6 +175,7 @@ WHERE id = ? static String? _remoteCoverUrl(String? coverUrl) { final uri = coverUrl == null ? null : Uri.tryParse(coverUrl); + if (uri == null || !{'http', 'https'}.contains(uri.scheme) || uri.host.isEmpty) { return null; } diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index 63a10a34..86c9fd2d 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -52,11 +52,13 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac 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(); + final StreamController _bookMetadataSyncStateController = StreamController.broadcast(); @@ -69,26 +71,38 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac LibraryDatabase get _activeLibrary { final library = _library; - if (library == null) throw StateError('Library database is not active'); + + if (library == null) { + throw StateError('Library database is not active'); + } + return library; } @override EditableBookRepository get scopedBooks => _activeLibrary.books; + @override EntityRepository get shelves => _activeLibrary.shelves; + @override EntityRepository get tags => _activeLibrary.tags; + @override EntityRepository get notes => _activeLibrary.notes; + @override EntityRepository get annotations => _activeLibrary.annotations; + @override EntityRepository get bookmarks => _activeLibrary.bookmarks; + @override EntityRepository get bookShelves => _activeLibrary.bookShelves; + @override EntityRepository get bookTags => _activeLibrary.bookTags; + @override LibraryMembershipWriter get memberships => _activeLibrary; @@ -96,9 +110,14 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Stream watchLibrary() => Stream.multi((listener) { final subscription = _libraryController.stream.listen(listener.addSync, onError: listener.addErrorSync); final current = _snapshot; - if (current != null) listener.addSync(current); + + if (current != null) { + listener.addSync(current); + } + listener.onCancel = subscription.cancel; }, isBroadcast: true); + StreamSubscription? _booksSubscription; StreamSubscription? _statusSubscription; Future? _modeOperation; @@ -118,8 +137,10 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac LibraryDatabaseMode? get mode => _mode; SyncState get syncState => _syncState; + Stream get syncStates => streamWithCurrentValue(currentValue: () => _syncState, updates: _syncStateController.stream); + BookMetadataSyncState get bookMetadataSyncState => _bookMetadataSyncState; Stream get bookMetadataSyncStates => _bookMetadataSyncStateController.stream; @@ -135,10 +156,13 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future setOnline(bool online) async { await _modeOperation; + if (_mode != LibraryDatabaseMode.authenticated) { throw StateError('Only authenticated libraries can connect to PowerSync'); } + final database = _requireDatabase(); + if (online) { await _prepareTracking(); await database.connect(connector: connectorFactory()); @@ -149,9 +173,11 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future reconnect() async { await _modeOperation; + if (_mode != LibraryDatabaseMode.authenticated) { throw StateError('Only authenticated libraries can connect to PowerSync'); } + final database = _requireDatabase(); _watchStatus(database); await database.disconnect(); @@ -161,15 +187,19 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future clearGuestLibrary() async { await _modeOperation; + if (_mode != LibraryDatabaseMode.guest) { throw StateError('Only guest libraries can be cleared with clearGuestLibrary'); } + final database = _requireDatabase(); + await database.writeTransaction((tx) async { for (final table in libraryTableNames.reversed) { await tx.execute('DELETE FROM $table'); } }); + _booksController.add(const []); _setSyncState(const SyncState()); _setBookMetadataSyncState(const BookMetadataSyncState()); @@ -177,11 +207,14 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future clearAuthenticatedCache() async { await _modeOperation; + if (_mode != LibraryDatabaseMode.authenticated) { throw StateError('Only authenticated libraries can clear the account cache'); } + final userId = _authenticatedUserId; final profileKey = _authenticatedProfileKey ?? 'official'; + if (userId == null) { throw StateError('Authenticated library is missing a user id'); } @@ -239,17 +272,22 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future _switchMode(LibraryDatabaseMode mode, {String? authenticatedUserId, String? authenticatedProfileKey}) { final previousOperation = _modeOperation; + final operation = (() async { await previousOperation; + if (_mode == mode && _database != null && (mode == LibraryDatabaseMode.guest || (_authenticatedUserId == authenticatedUserId && _authenticatedProfileKey == authenticatedProfileKey))) { return; } + await _performModeSwitch(mode, authenticatedUserId, authenticatedProfileKey); })(); + _modeOperation = operation; + return operation.whenComplete(() { if (identical(_modeOperation, operation)) { _modeOperation = null; @@ -273,6 +311,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac schema: mode == LibraryDatabaseMode.guest ? papyrusGuestSchema : papyrusAccountSchema, path: await _databasePath(mode), ); + await database.initialize(); _database = database; _library = LibraryDatabase(database, _refreshPendingWrites); @@ -288,18 +327,25 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac _setSyncState(const SyncState()); _setBookMetadataSyncState(const BookMetadataSyncState()); } + await _refreshPendingWrites(); } Future? _trackingPreparation; LibraryDatabase? _trackingLibrary; + Future _prepareTracking() { final library = _activeLibrary; final pending = _trackingPreparation; - if (pending != null && identical(_trackingLibrary, library)) return pending; + + 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; @@ -311,47 +357,64 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac 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; + if (library.active) { + library.trackingSchemaVersion = 0; + } } } void _watchBooks(PowerSyncDatabase database) { unawaited(_booksSubscription?.cancel()); final library = _activeLibrary; + _booksSubscription = database .watch('SELECT count(*) FROM books', triggerOnTables: libraryTableNames) .asyncMap((_) => library.snapshot()) .listen( (snapshot) { - if (!library.active) return; + if (!library.active) { + return; + } + _snapshot = snapshot; _booksController.add(snapshot.books); _libraryController.add(snapshot); }, onError: (Object error, StackTrace stack) { - if (library.active) _libraryController.addError(error, stack); + if (library.active) { + _libraryController.addError(error, stack); + } }, ); } void _watchStatus(PowerSyncDatabase database) { unawaited(_statusSubscription?.cancel()); + _statusSubscription = database.statusStream.listen((status) async { await _setStatusFromPowerSync(status); }); + unawaited(_setStatusFromPowerSync(database.currentStatus)); } Future _setStatusFromPowerSync(SyncStatus status) async { - if (status.connected && !_syncState.connected) unawaited(_prepareTracking()); + if (status.connected && !_syncState.connected) { + unawaited(_prepareTracking()); + } + final revision = _syncStateRevisions.beginTransportUpdate(); final pending = await _readPendingWrites(); - if (!_syncStateRevisions.isCurrent(revision)) return; + + if (!_syncStateRevisions.isCurrent(revision)) { + return; + } _setBookMetadataSyncState( BookMetadataSyncState( @@ -359,6 +422,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac failedBookIds: status.uploadError == null ? const {} : pending.bookIds, ), ); + _setSyncState( SyncState( connected: status.connected, @@ -376,15 +440,20 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future _refreshPendingWrites() async { final revision = _syncStateRevisions.observeForPendingRefresh(); final pending = await _readPendingWrites(); - if (!_syncStateRevisions.isCurrent(revision)) return; + + if (!_syncStateRevisions.isCurrent(revision)) { + return; + } final current = _syncState; + _setBookMetadataSyncState( BookMetadataSyncState( pendingBookIds: pending.bookIds, failedBookIds: current.uploadError == null ? const {} : pending.bookIds, ), ); + _setSyncState( SyncState( connected: current.connected, @@ -403,29 +472,40 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac Future<_PendingWrites> _readPendingWrites() async { final database = _database; + if (database == null || _mode != LibraryDatabaseMode.authenticated) { return const _PendingWrites(); } + final rows = await database.getAll('SELECT data FROM ps_crud'); final bookIds = {}; + for (final row in rows) { final rawData = row['data']; - if (rawData is! String) continue; + + if (rawData is! String) { + continue; + } + final Object? decoded; + try { decoded = jsonDecode(rawData); } on FormatException { continue; } + if (decoded case {'type': 'books', 'id': final String id}) { bookIds.add(id); } } + return _PendingWrites(any: rows.isNotEmpty, bookIds: bookIds); } void _setSyncState(SyncState state) { _syncState = state; + if (!_syncStateController.isClosed) { _syncStateController.add(state); } @@ -433,6 +513,7 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac void _setBookMetadataSyncState(BookMetadataSyncState state) { _bookMetadataSyncState = state; + if (!_bookMetadataSyncStateController.isClosed) { _bookMetadataSyncStateController.add(state); } @@ -440,15 +521,18 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac PowerSyncDatabase _requireDatabase() { final database = _database; + if (database == null) { throw StateError('Library database is not active'); } + return database; } Future _closeActive({required bool clearAuthenticated}) async { _syncStateRevisions.invalidate(); final library = _library; + if (library != null) { library.active = false; _library = null; @@ -456,14 +540,15 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac _booksController.add(const []); _libraryController.add(_snapshot!); } + await _booksSubscription?.cancel(); await _statusSubscription?.cancel(); _booksSubscription = null; _statusSubscription = null; - final database = _database; final mode = _mode; _database = null; + if (database == null) { return; } @@ -473,11 +558,13 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac } else if (mode == LibraryDatabaseMode.authenticated) { await database.disconnect(); } + await database.close(); } Future _databasePath(LibraryDatabaseMode mode) async { final customResolver = pathResolver; + if (customResolver != null) { return customResolver(mode, _authenticatedProfileKey, _authenticatedUserId); } @@ -485,9 +572,11 @@ class PapyrusPowerSyncService implements BookRepository, LibraryRepository, Trac final fileName = mode == LibraryDatabaseMode.guest ? 'papyrus-guest.db' : 'papyrus-account-${_safeFileComponent(_authenticatedProfileKey ?? 'official')}-${_safeFileComponent(_authenticatedUserId ?? 'anonymous')}.db'; + if (kIsWeb) { return fileName; } + final directory = await getApplicationSupportDirectory(); return path.join(directory.path, fileName); } diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index 0ed42379..84e30eaf 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -30,8 +30,14 @@ class StorageSyncController { bool get isSignedOut => !authProvider.isSignedIn && !authProvider.isOfflineMode; String get modeLabel { - if (isGuest) return 'Guest local'; - if (isAuthenticated) return 'Account synced'; + if (isGuest) { + return 'Guest local'; + } + + if (isAuthenticated) { + return 'Account synced'; + } + return 'Signed out'; } @@ -47,13 +53,22 @@ class StorageSyncController { } String get backendLabel { - if (isAuthenticated) return syncSettings.activeServerLabel; - if (isGuest) return 'Local only'; + if (isAuthenticated) { + return syncSettings.activeServerLabel; + } + + if (isGuest) { + return 'Local only'; + } + return 'Not connected'; } String get dataSyncLabel { - if (isGuest) return 'Data sync off'; + if (isGuest) { + return 'Data sync off'; + } + return syncSettings.activeServerLabel; } @@ -61,30 +76,59 @@ class StorageSyncController { String get fileStorageLabel { final usage = mediaStorageUsage; + if (isAuthenticated && usage != null) { return syncSettings.fileStorageLabel(usedBytes: usage.usedBytes, quotaBytesOverride: usage.quotaBytes); } + return syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); } String get statusLabel { - if (isGuest) return 'Guest local'; - if (isSignedOut) return 'Signed out'; - if (syncState.uploadError != null || syncState.downloadError != null) return 'Error'; - if (syncState.connecting) return 'Connecting'; - if (syncState.uploading || syncState.downloading) return 'Syncing'; + if (isGuest) { + return 'Guest local'; + } + + if (isSignedOut) { + return 'Signed out'; + } + + if (syncState.uploadError != null || syncState.downloadError != null) { + return 'Error'; + } + + if (syncState.connecting) { + return 'Connecting'; + } + + if (syncState.uploading || syncState.downloading) { + return 'Syncing'; + } + if (syncState.connected) { return syncState.hasPendingWrites ? 'Waiting to sync' : 'Connected'; } + return 'Offline'; } String get syncDetail { final error = syncState.uploadError ?? syncState.downloadError; - if (error != null) return 'Sync error: $error'; - if (syncState.hasPendingWrites) return 'Changes will sync automatically'; + + if (error != null) { + return 'Sync error: $error'; + } + + if (syncState.hasPendingWrites) { + return 'Changes will sync automatically'; + } + final lastSyncedAt = syncState.lastSyncedAt; - if (lastSyncedAt == null) return 'No completed sync yet'; + + if (lastSyncedAt == null) { + return 'No completed sync yet'; + } + return 'Last sync: ${lastSyncedAt.toLocal()}'; } @@ -94,7 +138,10 @@ class StorageSyncController { bool get hasFailedMediaUploads => failedMediaUploadCount > 0; String get failedMediaUploadLabel { - if (failedMediaUploadCount == 1) return '1 failed'; + if (failedMediaUploadCount == 1) { + return '1 failed'; + } + return '$failedMediaUploadCount failed'; } diff --git a/app/lib/powersync/sync_profile_switch_queue.dart b/app/lib/powersync/sync_profile_switch_queue.dart index 503bfa5c..e5689b4f 100644 --- a/app/lib/powersync/sync_profile_switch_queue.dart +++ b/app/lib/powersync/sync_profile_switch_queue.dart @@ -15,9 +15,11 @@ class SyncProfileSwitchQueue { String get requestedProfileKey => _requestedProfileKey; bool request(String profileKey, Future Function() switchProfile) { - if (profileKey == _requestedProfileKey) return false; - _requestedProfileKey = profileKey; + if (profileKey == _requestedProfileKey) { + return false; + } + _requestedProfileKey = profileKey; final operation = _tail.then((_) => switchProfile()); _tail = operation.then((_) {}, onError: _onError); return true; diff --git a/app/lib/providers/acquisition_availability_provider.dart b/app/lib/providers/acquisition_availability_provider.dart index edcf38ca..6178f820 100644 --- a/app/lib/providers/acquisition_availability_provider.dart +++ b/app/lib/providers/acquisition_availability_provider.dart @@ -39,6 +39,7 @@ class AcquisitionAvailabilityProvider extends ChangeNotifier { if (_state == AcquisitionAvailabilityState.loading) { return _refreshOperation ?? Future.value(); } + if (!force && _state != AcquisitionAvailabilityState.unknown) { return Future.value(); } @@ -51,9 +52,9 @@ class AcquisitionAvailabilityProvider extends ChangeNotifier { final generation = ++_generation; _state = AcquisitionAvailabilityState.loading; notifyListeners(); - final operation = _load(serverBaseUri, generation); _refreshOperation = operation; + return operation.whenComplete(() { if (identical(_refreshOperation, operation)) { _refreshOperation = null; @@ -80,6 +81,7 @@ class AcquisitionAvailabilityProvider extends ChangeNotifier { final capabilities = await (_loadCapabilities != null ? _loadCapabilities(serverBaseUri) : _loadFromServer(serverBaseUri)); + if (generation != _generation || _serverBaseUri != serverBaseUri) { return; } diff --git a/app/lib/providers/acquisition_downloads_provider.dart b/app/lib/providers/acquisition_downloads_provider.dart index 0c747dbd..8b47238c 100644 --- a/app/lib/providers/acquisition_downloads_provider.dart +++ b/app/lib/providers/acquisition_downloads_provider.dart @@ -47,6 +47,7 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs visiblePollingInterval = pollingInterval ?? visiblePollingInterval, foregroundPollingInterval = pollingInterval ?? foregroundPollingInterval { WidgetsBinding.instance.addObserver(this); + _isForeground = switch (WidgetsBinding.instance.lifecycleState) { null || AppLifecycleState.resumed => true, _ => false, @@ -55,11 +56,13 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs List get jobs { final values = _jobs.values.toList(); + values.sort((a, b) { final left = a.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); final right = b.createdAt ?? DateTime.fromMillisecondsSinceEpoch(0); return right.compareTo(left); }); + return values; } @@ -107,7 +110,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs bool get isManagedAcquisitionReady { final hasIndexer = _endpoints.any((endpoint) => endpoint.enabled && endpoint.kind.isIndexer); - return _serverManagedDownloadsReady && hasIndexer && downloadClients.isNotEmpty; } @@ -369,6 +371,7 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs _selectedReleaseTokens ..clear() ..addAll(_remoteResults.map((release) => release.releaseToken)); + _notifyListeners(); } @@ -501,9 +504,9 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs final failuresByReleaseToken = { for (final release in selected) release.releaseToken: submissionErrorMessage(error.toString()), }; + _selectedReleaseTokens.addAll(failuresByReleaseToken.keys); _submissionErrorsByReleaseToken = Map.unmodifiable(failuresByReleaseToken); - return AcquisitionSubmissionOutcome(successfulCount: 0, failuresByReleaseToken: failuresByReleaseToken); } } finally { @@ -544,7 +547,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs final message = listDownloadFilesErrorMessage(error); _error = message; _notifyListeners(); - return AcquisitionJobFilesResult.failure(message); } } @@ -566,7 +568,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs if (_isCurrent(gateway, generation)) { _replaceJob(job); - return const AcquisitionJobActionOutcome.success(); } } catch (error) { @@ -574,7 +575,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs final message = selectDownloadFileErrorMessage(error); _error = message; _notifyListeners(); - return AcquisitionJobActionOutcome.failure(message); } } @@ -596,7 +596,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs if (_isCurrent(gateway, generation)) { _replaceJob(job); - return const AcquisitionJobActionOutcome.success(); } } catch (error) { @@ -604,7 +603,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs final message = retryDownloadImportErrorMessage(error); _error = message; _notifyListeners(); - return AcquisitionJobActionOutcome.failure(message); } } @@ -628,7 +626,6 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs _schedulePolling(); _notifyListeners(); - return outcome; } @@ -688,14 +685,12 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs if (_isCurrent(gateway, generation)) { _setJob(cancelled); _error = null; - return const AcquisitionJobActionOutcome.success(); } } catch (error) { if (_isCurrent(gateway, generation)) { final message = cancelDownloadErrorMessage(error); _error = message; - return AcquisitionJobActionOutcome.failure(message); } } @@ -807,6 +802,7 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs _jobs ..clear() ..addEntries(jobs.map((job) => MapEntry(job.id, job))); + _jobStateRevision += 1; } @@ -852,6 +848,7 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs if (_discoveryRefreshScheduled) { _discoveryRefreshRequiresLibraryVisibility = _discoveryRefreshRequiresLibraryVisibility && requireLibraryVisibility; + return; } @@ -861,6 +858,7 @@ class AcquisitionDownloadsProvider extends ChangeNotifier with WidgetsBindingObs scheduleMicrotask(() { final shouldRefresh = !_disposed && _isForeground && (!_discoveryRefreshRequiresLibraryVisibility || _isLibraryVisible); + _discoveryRefreshScheduled = false; _discoveryRefreshRequiresLibraryVisibility = true; diff --git a/app/lib/providers/annotations_provider.dart b/app/lib/providers/annotations_provider.dart index 5f861112..f79a3898 100644 --- a/app/lib/providers/annotations_provider.dart +++ b/app/lib/providers/annotations_provider.dart @@ -38,10 +38,6 @@ class AnnotationsProvider extends ChangeNotifier { super.dispose(); } - // ============================================================================ - // GETTERS - // ============================================================================ - AnnotationSortOption get sortOption => _sortOption; Set get activeColors => _selectedColors; String get searchQuery => _searchQuery; @@ -60,7 +56,10 @@ class AnnotationsProvider extends ChangeNotifier { /// All annotations, filtered and sorted. List get annotations { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + var list = List.from(_dataStore!.annotations); list = _applyFilters(list); _applySorting(list); @@ -71,9 +70,11 @@ class AnnotationsProvider extends ChangeNotifier { Map> get annotationsByBook { final filtered = annotations; final map = >{}; + for (final annotation in filtered) { map.putIfAbsent(annotation.bookId, () => []).add(annotation); } + return map; } @@ -91,10 +92,6 @@ class AnnotationsProvider extends ChangeNotifier { return _dataStore?.getBook(bookId)?.coverMediaId; } - // ============================================================================ - // SORTING & FILTERING - // ============================================================================ - void setSortOption(AnnotationSortOption option) { _sortOption = option; notifyListeners(); @@ -102,11 +99,13 @@ class AnnotationsProvider extends ChangeNotifier { void toggleColorFilter(HighlightColor color) { _selectedColors = Set.from(_selectedColors); + if (_selectedColors.contains(color)) { _selectedColors.remove(color); } else { _selectedColors.add(color); } + notifyListeners(); } @@ -131,10 +130,6 @@ class AnnotationsProvider extends ChangeNotifier { notifyListeners(); } - // ============================================================================ - // CRUD (delegated to DataStore) - // ============================================================================ - Future updateAnnotationNote( String annotationId, String? note, { @@ -142,7 +137,11 @@ class AnnotationsProvider extends ChangeNotifier { EntityRepository? repository, }) async { final annotation = previous ?? _dataStore?.getAnnotation(annotationId); - if (annotation == null || _dataStore == null) return; + + if (annotation == null || _dataStore == null) { + return; + } + await _dataStore!.updateAnnotation( annotation.copyWith(note: note, clearNote: note == null), previous: annotation, @@ -154,24 +153,22 @@ class AnnotationsProvider extends ChangeNotifier { await _dataStore?.deleteAnnotation(annotationId, repository: repository); } - // ============================================================================ - // PRIVATE HELPERS - // ============================================================================ - List _applyFilters(List all) { var result = all; if (_selectedColors.isNotEmpty) { - result = result.where((a) => _selectedColors.contains(a.color)).toList(); + result = result.where((annotation) => _selectedColors.contains(annotation.color)).toList(); } if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); - result = result.where((a) { - final bookTitle = getBookTitle(a.bookId).toLowerCase(); - final highlightText = a.highlightText.toLowerCase(); - final note = a.note?.toLowerCase() ?? ''; - final location = a.location.displayLocation.toLowerCase(); + + result = result.where((annotation) { + final bookTitle = getBookTitle(annotation.bookId).toLowerCase(); + final highlightText = annotation.highlightText.toLowerCase(); + final note = annotation.note?.toLowerCase() ?? ''; + final location = annotation.location.displayLocation.toLowerCase(); + return bookTitle.contains(query) || highlightText.contains(query) || note.contains(query) || @@ -183,16 +180,16 @@ class AnnotationsProvider extends ChangeNotifier { } void _applySorting(List list) { - list.sort((a, b) { + list.sort((left, right) { switch (_sortOption) { case AnnotationSortOption.dateNewest: - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); case AnnotationSortOption.dateOldest: - return a.createdAt.compareTo(b.createdAt); + return left.createdAt.compareTo(right.createdAt); case AnnotationSortOption.bookTitle: - return getBookTitle(a.bookId).toLowerCase().compareTo(getBookTitle(b.bookId).toLowerCase()); + return getBookTitle(left.bookId).toLowerCase().compareTo(getBookTitle(right.bookId).toLowerCase()); case AnnotationSortOption.position: - return a.location.pageNumber.compareTo(b.location.pageNumber); + return left.location.pageNumber.compareTo(right.location.pageNumber); } }); } diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index 9a9bd676..879f6546 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -194,6 +194,7 @@ class AuthProvider extends ChangeNotifier { _error = _messageFor(error); _setStatus(AuthStatus.signedOut); } + rethrow; } } diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index be2a5a08..e6a5d5a9 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -53,19 +53,28 @@ class BookDetailsProvider extends ChangeNotifier { /// Get bookmarks for the current book from DataStore. List get bookmarks { - if (_dataStore == null || _currentBookId == null) return []; + if (_dataStore == null || _currentBookId == null) { + return []; + } + return _dataStore!.getBookmarksForBook(_currentBookId!); } /// Get annotations for the current book from DataStore. List get annotations { - if (_dataStore == null || _currentBookId == null) return []; + if (_dataStore == null || _currentBookId == null) { + return []; + } + return _dataStore!.getAnnotationsForBook(_currentBookId!); } /// Get notes for the current book from DataStore. List get notes { - if (_dataStore == null || _currentBookId == null) return []; + if (_dataStore == null || _currentBookId == null) { + return []; + } + return _dataStore!.getNotesForBook(_currentBookId!); } @@ -97,11 +106,13 @@ class BookDetailsProvider extends ChangeNotifier { // Just refresh from DataStore in case data changed if (_dataStore != null) { final updatedBook = _dataStore!.getBook(bookId); + if (updatedBook != null && updatedBook != _book) { _book = updatedBook; notifyListeners(); } } + return; } @@ -113,10 +124,12 @@ class BookDetailsProvider extends ChangeNotifier { try { // Find book from DataStore (or sample data as fallback) Book? foundBook; + if (_dataStore != null) { final dataStore = _dataStore!; final repository = dataStore.requireBookRepository(); foundBook = dataStore.getBook(bookId) ?? await repository.getById(bookId); + if (foundBook == null && !dataStore.isLoaded) { await dataStore.waitUntilLoaded(); foundBook = dataStore.getBook(bookId) ?? await repository.getById(bookId); @@ -124,7 +137,7 @@ class BookDetailsProvider extends ChangeNotifier { } // Fallback to sample data if not found in DataStore - foundBook ??= SampleData.books.cast().firstWhere((b) => b?.id == bookId, orElse: () => null); + foundBook ??= SampleData.books.cast().firstWhere((book) => book?.id == bookId, orElse: () => null); if (foundBook == null) { throw Exception('Book not found'); @@ -134,8 +147,8 @@ class BookDetailsProvider extends ChangeNotifier { // Notes and annotations are now fetched dynamically from DataStore // via the getters, so no need to load them here _error = null; - } catch (e) { - _error = e.toString(); + } catch (error) { + _error = error.toString(); _book = null; _currentBookId = null; } finally { @@ -194,7 +207,11 @@ class BookDetailsProvider extends ChangeNotifier { EntityRepository? repository, }) async { final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); - if (bookmark == null || _dataStore == null) return; + + if (bookmark == null || _dataStore == null) { + return; + } + await _dataStore!.updateBookmark( bookmark.copyWith(note: note), previous: bookmark, @@ -210,7 +227,11 @@ class BookDetailsProvider extends ChangeNotifier { EntityRepository? repository, }) async { final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); - if (bookmark == null || _dataStore == null) return; + + if (bookmark == null || _dataStore == null) { + return; + } + await _dataStore!.updateBookmark( bookmark.copyWith(colorHex: colorHex), previous: bookmark, @@ -238,7 +259,11 @@ class BookDetailsProvider extends ChangeNotifier { EntityRepository? repository, }) async { final annotation = previous ?? _dataStore?.getAnnotation(annotationId); - if (annotation == null || _dataStore == null) return; + + if (annotation == null || _dataStore == null) { + return; + } + await _dataStore!.updateAnnotation( annotation.copyWith(note: note, clearNote: note == null), previous: annotation, @@ -269,9 +294,11 @@ class BookDetailsProvider extends ChangeNotifier { void toggleFavorite() { if (_book != null) { _book = _book!.copyWith(isFavorite: !_book!.isFavorite); + if (_dataStore != null) { _dataStore!.updateBook(_book!); } + notifyListeners(); } } @@ -280,16 +307,20 @@ class BookDetailsProvider extends ChangeNotifier { void updateProgress(double progress) { if (_book != null) { _book = _book!.copyWith(currentPosition: progress.clamp(0.0, 1.0)); + if (_dataStore != null) { _dataStore!.updateBook(_book!); } + notifyListeners(); } } /// Update reading progress by page number. Calculates position from page count. void updatePageProgress(int page, double position) { - if (_book == null) return; + if (_book == null) { + return; + } _book = _book!.copyWith( currentPage: page, @@ -299,9 +330,11 @@ class BookDetailsProvider extends ChangeNotifier { : _book!.readingStatus, lastReadAt: DateTime.now(), ); + if (_dataStore != null) { _dataStore!.updateBook(_book!); } + notifyListeners(); } diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index 0bd7a6e3..abfc810a 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -31,10 +31,6 @@ class BookEditProvider extends ChangeNotifier { BookEditProvider({MetadataService? metadataService}) : _metadataService = metadataService ?? MetadataService(); - // ============================================================================ - // GETTERS - // ============================================================================ - Book? get originalBook => _originalBook; Book? get editedBook => _editedBook; bool get isLoading => _isLoading; @@ -52,19 +48,21 @@ class BookEditProvider extends ChangeNotifier { /// Whether there are unsaved changes. bool get hasUnsavedChanges { - if (_originalBook == null || _editedBook == null) return false; + if (_originalBook == null || _editedBook == null) { + return false; + } // Check for local cover image as well - if (_coverImageBytes != null) return true; + + if (_coverImageBytes != null) { + return true; + } + return _originalBook != _editedBook; } /// Whether the form can be saved. bool get canSave => hasUnsavedChanges && !_isSaving; - // ============================================================================ - // DATA STORE - // ============================================================================ - /// Set the data store reference. void setDataStore(DataStore dataStore) { _dataStore = dataStore; @@ -87,18 +85,20 @@ class BookEditProvider extends ChangeNotifier { final repository = dataStore.requireBookRepository(); _repository = repository; var book = dataStore.getBook(bookId) ?? await repository.getById(bookId); + if (book == null && !dataStore.isLoaded) { await dataStore.waitUntilLoaded(); book = dataStore.getBook(bookId) ?? await repository.getById(bookId); } + if (book == null) { _error = 'Book not found'; } else { _originalBook = book; _editedBook = book; } - } catch (e) { - _error = 'Failed to load book: $e'; + } catch (error) { + _error = 'Failed to load book: $error'; } finally { _isLoading = false; notifyListeners(); @@ -107,7 +107,9 @@ class BookEditProvider extends ChangeNotifier { /// Save the edited book to the data store. Future save() async { - if (_dataStore == null || _editedBook == null) return false; + if (_dataStore == null || _editedBook == null) { + return false; + } _isSaving = true; _error = null; @@ -121,8 +123,8 @@ class BookEditProvider extends ChangeNotifier { _isSaving = false; notifyListeners(); return true; - } catch (e) { - _error = 'Failed to save book: $e'; + } catch (error) { + _error = 'Failed to save book: $error'; _isSaving = false; notifyListeners(); return false; @@ -137,84 +139,115 @@ class BookEditProvider extends ChangeNotifier { } } - // ============================================================================ - // FIELD UPDATES - // ============================================================================ - void updateTitle(String value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(title: value); notifyListeners(); } void updateSubtitle(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(subtitle: shouldClear ? null : value, clearSubtitle: shouldClear); notifyListeners(); } void updateAuthor(String value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(author: value); notifyListeners(); } void updateCoAuthors(List value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(coAuthors: value); notifyListeners(); } void updatePublisher(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(publisher: shouldClear ? null : value, clearPublisher: shouldClear); notifyListeners(); } void updateLanguage(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(language: shouldClear ? null : value, clearLanguage: shouldClear); notifyListeners(); } void updatePageCount(int? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(pageCount: value, clearPageCount: value == null); notifyListeners(); } void updateIsbn(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(isbn: shouldClear ? null : value, clearIsbn: shouldClear); notifyListeners(); } void updateIsbn13(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(isbn13: shouldClear ? null : value, clearIsbn13: shouldClear); notifyListeners(); } void updateDescription(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(description: shouldClear ? null : value, clearDescription: shouldClear); notifyListeners(); } void updateCoverUrl(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(coverUrl: shouldClear ? null : value, clearCoverUrl: shouldClear); // Clear local bytes when URL is set + if (value != null && value.isNotEmpty) { _coverImageBytes = null; } + notifyListeners(); } @@ -222,70 +255,94 @@ class BookEditProvider extends ChangeNotifier { void updateCoverFromFile(Uint8List? bytes) { _coverImageBytes = bytes; // Clear URL when file is uploaded + if (bytes != null && _editedBook != null) { _editedBook = _editedBook!.copyWith(clearCoverUrl: true); } + notifyListeners(); } void updatePublicationDate(DateTime? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(publicationDate: value, clearPublicationDate: value == null); notifyListeners(); } void updateRating(int? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(rating: value, clearRating: value == null); notifyListeners(); } void updateSeriesName(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(seriesName: shouldClear ? null : value, clearSeriesName: shouldClear); notifyListeners(); } void updateSeriesNumber(double? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(seriesNumber: value, clearSeriesNumber: value == null); notifyListeners(); } void updateIsPhysical(bool value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(isPhysical: value); notifyListeners(); } void updatePhysicalLocation(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith( physicalLocation: shouldClear ? null : value, clearPhysicalLocation: shouldClear, ); + notifyListeners(); } void updateLentTo(String? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + final shouldClear = value == null || value.isEmpty; _editedBook = _editedBook!.copyWith(lentTo: shouldClear ? null : value, clearLentTo: shouldClear); notifyListeners(); } void updateLentAt(DateTime? value) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } + _editedBook = _editedBook!.copyWith(lentAt: value, clearLentAt: value == null); notifyListeners(); } - // ============================================================================ - // METADATA FETCH - // ============================================================================ - /// Set the metadata source for searches. void setMetadataSource(MetadataSource source) { _selectedSource = source; @@ -294,7 +351,9 @@ class BookEditProvider extends ChangeNotifier { /// Search for book metadata by query. Future searchMetadata(String query) async { - if (query.trim().isEmpty) return; + if (query.trim().isEmpty) { + return; + } _fetchState = MetadataFetchState.loading; _fetchError = null; @@ -305,6 +364,7 @@ class BookEditProvider extends ChangeNotifier { final results = await _metadataService.search(query, _selectedSource); _fetchedResults = results; _fetchState = results.isEmpty ? MetadataFetchState.error : MetadataFetchState.success; + if (results.isEmpty) { _fetchError = 'No results found'; } @@ -318,7 +378,9 @@ class BookEditProvider extends ChangeNotifier { /// Search for book metadata by ISBN. Future searchMetadataByIsbn(String isbn) async { - if (isbn.trim().isEmpty) return; + if (isbn.trim().isEmpty) { + return; + } _fetchState = MetadataFetchState.loading; _fetchError = null; @@ -329,6 +391,7 @@ class BookEditProvider extends ChangeNotifier { final results = await _metadataService.searchByIsbn(isbn, _selectedSource); _fetchedResults = results; _fetchState = results.isEmpty ? MetadataFetchState.error : MetadataFetchState.success; + if (results.isEmpty) { _fetchError = 'No results found for ISBN'; } @@ -342,25 +405,37 @@ class BookEditProvider extends ChangeNotifier { /// Try to parse a date string in various formats (yyyy-MM-dd, yyyy-MM, yyyy). DateTime? _tryParseDate(String? dateStr) { - if (dateStr == null || dateStr.isEmpty) return null; + if (dateStr == null || dateStr.isEmpty) { + return null; + } // Try full date: yyyy-MM-dd + final fullDate = DateTime.tryParse(dateStr); - if (fullDate != null) return fullDate; + + if (fullDate != null) { + return fullDate; + } // Try year-month: yyyy-MM + if (RegExp(r'^\d{4}-\d{2}$').hasMatch(dateStr)) { return DateTime.tryParse('$dateStr-01'); } // Try year only: yyyy + final year = int.tryParse(dateStr); + if (year != null && year > 0 && year < 10000) { return DateTime(year); } + return null; } /// Apply fetched metadata to the edited book. void applyMetadata(BookMetadataResult result) { - if (_editedBook == null) return; + if (_editedBook == null) { + return; + } final parsedDate = _tryParseDate(result.publishedDate); diff --git a/app/lib/providers/book_storage_status_controller.dart b/app/lib/providers/book_storage_status_controller.dart index 5c327f9b..79fc9038 100644 --- a/app/lib/providers/book_storage_status_controller.dart +++ b/app/lib/providers/book_storage_status_controller.dart @@ -22,15 +22,20 @@ BookAccountStatus? resolveBookAccountStatus({ bool metadataFailed = false, List mediaTasks = const [], }) { - if (!isAccountLibrary) return null; + if (!isAccountLibrary) { + return null; + } final bookFileTasks = mediaTasks.where((task) => task.bookId == book.id && task.kind == MediaKind.bookFile); + if (metadataFailed || bookFileTasks.any((task) => task.status == MediaUploadTaskStatus.failed)) { return BookAccountStatus.failed; } + if (metadataPending || bookFileTasks.isNotEmpty || (!book.isPhysical && book.fileMediaId == null)) { return BookAccountStatus.syncing; } + return BookAccountStatus.saved; } @@ -57,6 +62,7 @@ class BookStorageStatusController extends ChangeNotifier { _metadataState = powerSyncService.bookMetadataSyncState { authProvider.addListener(_notify); mediaUploadQueue.addListener(_notify); + _metadataSubscription = powerSyncService.bookMetadataSyncStates.listen((state) { _metadataState = state; notifyListeners(); @@ -74,12 +80,14 @@ class BookStorageStatusController extends ChangeNotifier { final authProvider = _authProvider; final powerSyncService = _powerSyncService; final mediaUploadQueue = _mediaUploadQueue; + final isAccountLibrary = authProvider != null && powerSyncService != null && authProvider.isSignedIn && !authProvider.isOfflineMode && powerSyncService.mode == LibraryDatabaseMode.authenticated; + return resolveBookAccountStatus( book: book, isAccountLibrary: isAccountLibrary, @@ -90,7 +98,10 @@ class BookStorageStatusController extends ChangeNotifier { } BookDeviceStatus? deviceStatus(Book book) { - if (book.isPhysical) return null; + if (book.isPhysical) { + return null; + } + return _deviceStatuses[book.id] ?? BookDeviceStatus.checking; } @@ -98,6 +109,7 @@ class BookStorageStatusController extends ChangeNotifier { if (book.isPhysical || _deviceStatuses.containsKey(book.id)) { return Future.value(); } + return _deviceChecks.putIfAbsent(book.id, () async { try { final exists = await _hasBookFile(book.id); @@ -114,7 +126,10 @@ class BookStorageStatusController extends ChangeNotifier { } void markAvailable(String bookId) { - if (_deviceStatuses[bookId] == BookDeviceStatus.available) return; + if (_deviceStatuses[bookId] == BookDeviceStatus.available) { + return; + } + _deviceStatuses[bookId] = BookDeviceStatus.available; notifyListeners(); } diff --git a/app/lib/providers/bookmarks_provider.dart b/app/lib/providers/bookmarks_provider.dart index d04a64f9..47614023 100644 --- a/app/lib/providers/bookmarks_provider.dart +++ b/app/lib/providers/bookmarks_provider.dart @@ -38,10 +38,6 @@ class BookmarksProvider extends ChangeNotifier { super.dispose(); } - // ============================================================================ - // GETTERS - // ============================================================================ - BookmarkSortOption get sortOption => _sortOption; Set get activeColors => _selectedColors; String get searchQuery => _searchQuery; @@ -60,7 +56,10 @@ class BookmarksProvider extends ChangeNotifier { /// All bookmarks, filtered and sorted. List get bookmarks { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + var list = List.from(_dataStore!.bookmarks); list = _applyFilters(list); _applySorting(list); @@ -71,9 +70,11 @@ class BookmarksProvider extends ChangeNotifier { Map> get bookmarksByBook { final filtered = bookmarks; final map = >{}; + for (final bookmark in filtered) { map.putIfAbsent(bookmark.bookId, () => []).add(bookmark); } + return map; } @@ -91,10 +92,6 @@ class BookmarksProvider extends ChangeNotifier { return _dataStore?.getBook(bookId)?.coverMediaId; } - // ============================================================================ - // SORTING & FILTERING - // ============================================================================ - void setSortOption(BookmarkSortOption option) { _sortOption = option; notifyListeners(); @@ -102,11 +99,13 @@ class BookmarksProvider extends ChangeNotifier { void toggleColorFilter(String colorHex) { _selectedColors = Set.from(_selectedColors); + if (_selectedColors.contains(colorHex)) { _selectedColors.remove(colorHex); } else { _selectedColors.add(colorHex); } + notifyListeners(); } @@ -131,10 +130,6 @@ class BookmarksProvider extends ChangeNotifier { notifyListeners(); } - // ============================================================================ - // CRUD (delegated to DataStore) - // ============================================================================ - Future updateBookmarkNote( String bookmarkId, String? note, { @@ -142,7 +137,11 @@ class BookmarksProvider extends ChangeNotifier { EntityRepository? repository, }) async { final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); - if (bookmark == null || _dataStore == null) return; + + if (bookmark == null || _dataStore == null) { + return; + } + await _dataStore!.updateBookmark( bookmark.copyWith(note: note), previous: bookmark, @@ -157,7 +156,11 @@ class BookmarksProvider extends ChangeNotifier { EntityRepository? repository, }) async { final bookmark = previous ?? _dataStore?.getBookmark(bookmarkId); - if (bookmark == null || _dataStore == null) return; + + if (bookmark == null || _dataStore == null) { + return; + } + await _dataStore!.updateBookmark( bookmark.copyWith(colorHex: colorHex), previous: bookmark, @@ -169,23 +172,20 @@ class BookmarksProvider extends ChangeNotifier { await _dataStore?.deleteBookmark(bookmarkId, repository: repository); } - // ============================================================================ - // PRIVATE HELPERS - // ============================================================================ - List _applyFilters(List all) { var result = all; if (_selectedColors.isNotEmpty) { - result = result.where((b) => _selectedColors.contains(b.colorHex)).toList(); + result = result.where((book) => _selectedColors.contains(book.colorHex)).toList(); } if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); - result = result.where((b) { - final bookTitle = getBookTitle(b.bookId).toLowerCase(); - final note = b.note?.toLowerCase() ?? ''; - final chapter = b.chapterTitle?.toLowerCase() ?? ''; + + result = result.where((book) { + final bookTitle = getBookTitle(book.bookId).toLowerCase(); + final note = book.note?.toLowerCase() ?? ''; + final chapter = book.chapterTitle?.toLowerCase() ?? ''; return bookTitle.contains(query) || note.contains(query) || chapter.contains(query); }).toList(); } @@ -194,16 +194,16 @@ class BookmarksProvider extends ChangeNotifier { } void _applySorting(List list) { - list.sort((a, b) { + list.sort((left, right) { switch (_sortOption) { case BookmarkSortOption.dateNewest: - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); case BookmarkSortOption.dateOldest: - return a.createdAt.compareTo(b.createdAt); + return left.createdAt.compareTo(right.createdAt); case BookmarkSortOption.bookTitle: - return getBookTitle(a.bookId).toLowerCase().compareTo(getBookTitle(b.bookId).toLowerCase()); + return getBookTitle(left.bookId).toLowerCase().compareTo(getBookTitle(right.bookId).toLowerCase()); case BookmarkSortOption.position: - return a.position.compareTo(b.position); + return left.position.compareTo(right.position); } }); } diff --git a/app/lib/providers/dashboard_provider.dart b/app/lib/providers/dashboard_provider.dart index 5f210cc9..3c07e066 100644 --- a/app/lib/providers/dashboard_provider.dart +++ b/app/lib/providers/dashboard_provider.dart @@ -46,27 +46,28 @@ class DashboardProvider extends ChangeNotifier { super.dispose(); } - // ============================================================================ - // GETTERS - // ============================================================================ - bool get isLoading => _isLoading; String? get error => _error; /// Get current book (most recently read with progress < 100%). Book? get currentBook { - if (_dataStore == null) return null; + if (_dataStore == null) { + return null; + } final readingBooks = _dataStore!.books.where((book) => book.readingStatus == LibraryReadingStatus.inProgress).toList() - ..sort((a, b) => (b.lastReadAt ?? DateTime(2000)).compareTo(a.lastReadAt ?? DateTime(2000))); + ..sort((left, right) => (right.lastReadAt ?? DateTime(2000)).compareTo(left.lastReadAt ?? DateTime(2000))); return readingBooks.isNotEmpty ? readingBooks.first : null; } /// Get active reading goals from DataStore. List get activeGoals { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + return _dataStore!.activeGoals; } @@ -74,14 +75,20 @@ class DashboardProvider extends ChangeNotifier { /// Get recently added books (last 5). List get recentlyAdded { - if (_dataStore == null) return []; - final books = List.from(_dataStore!.books)..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + if (_dataStore == null) { + return []; + } + + final books = List.from(_dataStore!.books)..sort((left, right) => right.addedAt.compareTo(left.addedAt)); return books.take(5).toList(); } /// Today's reading minutes from reading sessions. int get todayReadingMinutes { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + final today = DateTime.now(); final todayStart = DateTime(today.year, today.month, today.day); return _dataStore!.activityTotals(todayStart, DateTime(today.year, today.month, today.day + 1)).seconds ~/ 60; @@ -101,21 +108,27 @@ class DashboardProvider extends ChangeNotifier { /// Total reading minutes from all sessions. int get totalReadingMinutes { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + return _dataStore!.activityTotals(DateTime.utc(1900), DateTime.now().toUtc()).seconds ~/ 60; } ActivityPeriod get activityPeriod => _activityPeriod; - // ============================================================================ - // COMPUTED PROPERTIES - // ============================================================================ - /// Time-based greeting message. String get greeting { final hour = DateTime.now().hour; - if (hour < 12) return 'Good morning'; - if (hour < 17) return 'Good afternoon'; + + if (hour < 12) { + return 'Good morning'; + } + + if (hour < 17) { + return 'Good afternoon'; + } + return 'Good evening'; } @@ -128,9 +141,18 @@ class DashboardProvider extends ChangeNotifier { /// Today's reading time formatted. String get todayReadingLabel { final minutes = todayReadingMinutes; - if (minutes == 0) return '0 minutes'; - if (minutes == 1) return '1 minute'; - if (minutes < 60) return '$minutes minutes'; + + if (minutes == 0) { + return '0 minutes'; + } + + if (minutes == 1) { + return '1 minute'; + } + + if (minutes < 60) { + return '$minutes minutes'; + } final hours = minutes ~/ 60; final mins = minutes % 60; @@ -138,20 +160,21 @@ class DashboardProvider extends ChangeNotifier { if (mins == 0) { return hours == 1 ? '1 hour' : '$hours hours'; } + return '${hours}h ${mins}m'; } /// Total reading time formatted for quick stats. String get totalReadingLabel { final hours = totalReadingMinutes ~/ 60; - if (hours == 0) return '${totalReadingMinutes}m'; + + if (hours == 0) { + return '${totalReadingMinutes}m'; + } + return '${hours}h'; } - // ============================================================================ - // METHODS - // ============================================================================ - /// Loads all dashboard data. With DataStore, this is mainly for loading state UX. Future loadDashboardData() async { _isLoading = true; @@ -160,13 +183,11 @@ class DashboardProvider extends ChangeNotifier { try { await _dataStore?.waitUntilLoaded(); - _loadActivityData(); - _isLoading = false; notifyListeners(); - } catch (e) { - _error = 'Failed to load dashboard data: $e'; + } catch (error) { + _error = 'Failed to load dashboard data: $error'; _isLoading = false; notifyListeners(); } @@ -191,6 +212,7 @@ class DashboardProvider extends ChangeNotifier { } else { _monthOffset--; } + _loadActivityData(); notifyListeners(); } @@ -206,6 +228,7 @@ class DashboardProvider extends ChangeNotifier { _monthOffset++; } } + _loadActivityData(); notifyListeners(); } @@ -215,18 +238,31 @@ class DashboardProvider extends ChangeNotifier { if (_activityPeriod == ActivityPeriod.week) { return _weekOffset < 0; } + return _monthOffset < 0; } /// Get the period label (e.g., "This week", "Jan 15-21", "December 2024"). String get periodLabel { if (_activityPeriod == ActivityPeriod.week) { - if (_weekOffset == 0) return 'This week'; - if (_weekOffset == -1) return 'Last week'; + if (_weekOffset == 0) { + return 'This week'; + } + + if (_weekOffset == -1) { + return 'Last week'; + } + return _getWeekRangeLabel(_weekOffset); } else { - if (_monthOffset == 0) return 'This month'; - if (_monthOffset == -1) return 'Last month'; + if (_monthOffset == 0) { + return 'This month'; + } + + if (_monthOffset == -1) { + return 'Last month'; + } + return _getMonthLabel(_monthOffset); } } @@ -236,10 +272,6 @@ class DashboardProvider extends ChangeNotifier { await loadDashboardData(); } - // ============================================================================ - // PRIVATE METHODS - // ============================================================================ - void _loadActivityData() { if (_dataStore == null) { _weeklyActivity = []; @@ -251,22 +283,32 @@ class DashboardProvider extends ChangeNotifier { } List _generateActivityFromSessions(int offset) { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } final now = DateTime.now(); + 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(), + booksRead: totals.activities + .where((activity) => activity.kind == 'completion') + .map((activity) => activity.bookId) + .toSet() + .toList(), ); }); } @@ -275,12 +317,12 @@ class DashboardProvider extends ChangeNotifier { final now = DateTime.now(); final weekStart = now.subtract(Duration(days: now.weekday - 1 + (-offset * 7))); final weekEnd = weekStart.add(const Duration(days: 6)); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; if (weekStart.month == weekEnd.month) { return '${months[weekStart.month - 1]} ${weekStart.day}-${weekEnd.day}'; } + return '${months[weekStart.month - 1]} ${weekStart.day} - ${months[weekEnd.month - 1]} ${weekEnd.day}'; } @@ -288,10 +330,12 @@ class DashboardProvider extends ChangeNotifier { final now = DateTime.now(); var month = now.month + offset; var year = now.year; + while (month <= 0) { month += 12; year--; } + while (month > 12) { month -= 12; year++; diff --git a/app/lib/providers/goals_provider.dart b/app/lib/providers/goals_provider.dart index 18db5c98..79535e0b 100644 --- a/app/lib/providers/goals_provider.dart +++ b/app/lib/providers/goals_provider.dart @@ -24,22 +24,35 @@ class GoalsProvider extends ChangeNotifier { String? get error => _error; bool get isLoading => _store?.isLoaded != true; DateTime get now => _now().toUtc(); + void attach(DataStore dataStore) { - if (identical(_store, dataStore)) return; + if (identical(_store, dataStore)) { + return; + } + _store?.removeListener(_changed); _store = dataStore; dataStore.addListener(_changed); - if (watchClock) _clock ??= Timer.periodic(const Duration(minutes: 1), (_) => _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(); + if (identical(_store, dataStore)) { + _changed(); + } }); } void _changed() { notifyListeners(); - if (!_sealing && _store?.isLoaded == true) unawaited(_sealPeriods()); + + if (!_sealing && _store?.isLoaded == true) { + unawaited(_sealPeriods()); + } } List get current => store.goalDefinitions @@ -47,18 +60,22 @@ class GoalsProvider extends ChangeNotifier { .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, @@ -67,24 +84,32 @@ class GoalsProvider extends ChangeNotifier { period: GoalRange(record.definition.startDate, record.definition.endDate), ); } - final values = all.values.toList()..sort((a, b) => b.range.end.compareTo(a.range.end)); + + final values = all.values.toList()..sort((left, right) => right.range.end.compareTo(left.range.end)); return values; } Future _sealPeriods() async { _sealing = true; final target = store.trackingRepository; + try { 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; + + 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, @@ -95,10 +120,15 @@ class GoalsProvider extends ChangeNotifier { 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); + + if (records.isNotEmpty) { + await store.commitTracking(periods: records, repository: target); + } + _error = null; } catch (error) { _error = 'Could not save goal history: $error'; @@ -109,6 +139,7 @@ class GoalsProvider extends ChangeNotifier { Future loadGoals() => store.waitUntilLoaded(); Future refresh() => _sealPeriods(); + Future createGoal({ required GoalType type, required int target, @@ -127,24 +158,48 @@ class GoalsProvider extends ChangeNotifier { }) async { final origin = repository ?? store.trackingRepository; final replacing = replaceGoalId == null ? null : store.getReadingGoal(replaceGoalId); - if (replaceGoalId != null && replacing == null) throw StateError('This goal no longer exists.'); - if (target < 1 || minimumMinutes < 1 || minimumMinutes > 1440) throw ArgumentError('Targets must be positive.'); + + if (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 (selected.length > 1000) { + throw ArgumentError('Choose up to 1,000 books.'); + } + + if (scope == GoalScope.book && selected.isEmpty && scopeId != null) { + selected.add(scopeId); + } + if (scope == GoalScope.book && selected.isEmpty || scope == GoalScope.shelf && scopeId == null) { throw ArgumentError('Choose books or a shelf.'); } - if (scope != GoalScope.book && selected.isNotEmpty) throw ArgumentError('Book selection requires a book scope.'); + + if (scope != GoalScope.book && selected.isNotEmpty) { + throw ArgumentError('Book selection requires a book scope.'); + } + if (scope == GoalScope.book && type == GoalType.books && target > selected.length) { throw ArgumentError('Target cannot exceed the number of selected books.'); } + final zone = timezone ?? await GoalCalendar.deviceTimezone(); final created = now; + final range = period == GoalPeriod.custom ? 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.'); + + if (!range.end.isAfter(created)) { + throw ArgumentError('The deadline must be in the future.'); + } + final goal = ReadingGoal( id: const Uuid().v4(), type: type, @@ -162,6 +217,7 @@ class GoalsProvider extends ChangeNotifier { isRecurring: period != GoalPeriod.custom && isRecurring, rules: [GoalRule(at: created, target: target, title: title?.trim().isEmpty == true ? null : title)], ); + await store.commitTracking( goals: [if (replacing != null) _revisedGoal(replacing, archived: true, active: false), goal], repository: origin, @@ -176,7 +232,11 @@ class GoalsProvider extends ChangeNotifier { TrackingRepository? repository, }) async { final goal = store.getReadingGoal(goalId); - if (goal == null) throw StateError('This goal no longer exists.'); + + if (goal == null) { + throw StateError('This goal no longer exists.'); + } + if (type != null && type != goal.type) { await createGoal( type: type, @@ -193,15 +253,21 @@ class GoalsProvider extends ChangeNotifier { replaceGoalId: goalId, repository: repository, ); + return; } - if (target != null && target < 1) throw ArgumentError('Target must be positive.'); + + 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); } @@ -219,6 +285,7 @@ class GoalsProvider extends ChangeNotifier { ReadingGoal _revisedGoal(ReadingGoal goal, {int? target, String? title, bool? active, bool? archived}) { var at = now; + final rules = goal.rules.isEmpty ? [ GoalRule( @@ -230,7 +297,11 @@ class GoalsProvider extends ChangeNotifier { ), ] : [...goal.rules]; - if (!at.isAfter(rules.last.at)) at = rules.last.at.add(const Duration(microseconds: 1)); + + if (!at.isAfter(rules.last.at)) { + at = rules.last.at.add(const Duration(microseconds: 1)); + } + final rule = GoalRule( at: at, target: target ?? goal.targetValue, @@ -238,6 +309,7 @@ class GoalsProvider extends ChangeNotifier { active: active ?? goal.isActive, archived: archived ?? goal.isArchived, ); + return goal.copyWith( targetValue: rule.target, title: rule.title, @@ -249,15 +321,23 @@ class GoalsProvider extends ChangeNotifier { 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; + + 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, @@ -281,6 +361,7 @@ class GoalsProvider extends ChangeNotifier { ], ), ); + await store.commitTracking(periods: [record], deleteGoalId: id, repository: repository); } @@ -297,11 +378,14 @@ class GoalsProvider extends ChangeNotifier { 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 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( @@ -318,6 +402,7 @@ class GoalsProvider extends ChangeNotifier { ), ); } + if (finished) { activities.add( ReadingActivity( @@ -334,26 +419,36 @@ class GoalsProvider extends ChangeNotifier { ), ); } + 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); + + final updated = switch (finished) { + true => book.copyWith(readingStatus: LibraryReadingStatus.completed, completedAt: end), + false when correcting?.kind == 'completion' && !anotherCompletion => book.copyWith( + readingStatus: LibraryReadingStatus.inProgress, + clearCompletedAt: true, + ), + false => book.copyWith( + lastReadAt: book.lastReadAt != null && book.lastReadAt!.isAfter(end) ? book.lastReadAt : end, + ), + }; + await store.commitTracking(activities: activities, book: updated, previousBook: book, repository: repository); } 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), + (activity) => activity.id == original.id || original.constituentIds.contains(activity.id), )) store.reversalFor(entry, note: 'Undone from activity history'), ], diff --git a/app/lib/providers/library_provider.dart b/app/lib/providers/library_provider.dart index 4b1ce7a1..34786ad9 100644 --- a/app/lib/providers/library_provider.dart +++ b/app/lib/providers/library_provider.dart @@ -53,7 +53,11 @@ class LibraryProvider extends ChangeNotifier { void setGridItemWidth(double width) { final normalized = BookGridSize.normalize(width); - if (gridItemWidth == normalized) return; + + if (gridItemWidth == normalized) { + return; + } + if (_preferences != null) { _preferences.gridItemWidth = normalized; } else if (_favoriteDelegate != null) { @@ -94,7 +98,6 @@ class LibraryProvider extends ChangeNotifier { } _viewMode = mode; - notifyListeners(); } @@ -178,6 +181,7 @@ class LibraryProvider extends ChangeNotifier { } final isFavorite = isBookFavorite(book.id, book.isFavorite); + if (appliedFilters.favoriteFilter == FavoriteFilter.favorites && !isFavorite) { return false; } @@ -230,6 +234,7 @@ class LibraryProvider extends ChangeNotifier { if (appliedFilters.ratings.isNotEmpty || appliedFilters.includeUnrated) { final rating = book.rating; + if (rating == null ? !appliedFilters.includeUnrated : !appliedFilters.ratings.contains(rating)) { return false; } @@ -254,39 +259,57 @@ class LibraryProvider extends ChangeNotifier { List sortBooks(List books) { final sorted = List.of(books); - sorted.sort((a, b) { + sorted.sort((left, right) { switch (_sortOption) { case LibrarySortOption.dateAddedNewest: - return b.addedAt.compareTo(a.addedAt); + return right.addedAt.compareTo(left.addedAt); case LibrarySortOption.dateAddedOldest: - return a.addedAt.compareTo(b.addedAt); + return left.addedAt.compareTo(right.addedAt); case LibrarySortOption.titleAZ: - return a.title.toLowerCase().compareTo(b.title.toLowerCase()); + return left.title.toLowerCase().compareTo(right.title.toLowerCase()); case LibrarySortOption.titleZA: - return b.title.toLowerCase().compareTo(a.title.toLowerCase()); + return right.title.toLowerCase().compareTo(left.title.toLowerCase()); case LibrarySortOption.authorAZ: - return a.author.toLowerCase().compareTo(b.author.toLowerCase()); + return left.author.toLowerCase().compareTo(right.author.toLowerCase()); case LibrarySortOption.authorZA: - return b.author.toLowerCase().compareTo(a.author.toLowerCase()); + return right.author.toLowerCase().compareTo(left.author.toLowerCase()); case LibrarySortOption.lastRead: - if (a.lastReadAt == null && b.lastReadAt == null) return 0; - if (a.lastReadAt == null) return 1; - if (b.lastReadAt == null) return -1; - return b.lastReadAt!.compareTo(a.lastReadAt!); + if (left.lastReadAt == null && right.lastReadAt == null) { + return 0; + } + if (left.lastReadAt == null) { + return 1; + } + if (right.lastReadAt == null) { + return -1; + } + return right.lastReadAt!.compareTo(left.lastReadAt!); case LibrarySortOption.ratingAsc: - if (a.rating == null && b.rating == null) return 0; - if (a.rating == null) return 1; - if (b.rating == null) return -1; - return a.rating!.compareTo(b.rating!); + if (left.rating == null && right.rating == null) { + return 0; + } + if (left.rating == null) { + return 1; + } + if (right.rating == null) { + return -1; + } + return left.rating!.compareTo(right.rating!); case LibrarySortOption.ratingDesc: - if (a.rating == null && b.rating == null) return 0; - if (a.rating == null) return 1; - if (b.rating == null) return -1; - return b.rating!.compareTo(a.rating!); + if (left.rating == null && right.rating == null) { + return 0; + } + if (left.rating == null) { + return 1; + } + if (right.rating == null) { + return -1; + } + return right.rating!.compareTo(left.rating!); case LibrarySortOption.progressAsc: - return a.currentPosition.compareTo(b.currentPosition); + return left.currentPosition.compareTo(right.currentPosition); case LibrarySortOption.progressDesc: - return b.currentPosition.compareTo(a.currentPosition); + return right.currentPosition.compareTo(left.currentPosition); } }); @@ -314,26 +337,36 @@ class LibraryProvider extends ChangeNotifier { /// Check if a book is favorited (considering overrides). bool isBookFavorite(String bookId, bool originalFavorite) { final favoriteDelegate = _favoriteDelegate; + if (favoriteDelegate != null) { return favoriteDelegate.isBookFavorite(bookId, originalFavorite); } - if (_dataStore != null) return _dataStore.getBook(bookId)?.isFavorite ?? originalFavorite; + if (_dataStore != null) { + return _dataStore.getBook(bookId)?.isFavorite ?? originalFavorite; + } + return _favoriteOverrides[bookId] ?? originalFavorite; } /// Toggle the favorite status of a book. Future toggleFavorite(String bookId, bool currentFavorite) async { final favoriteDelegate = _favoriteDelegate; + if (favoriteDelegate != null) { await favoriteDelegate.toggleFavorite(bookId, currentFavorite); return; } final store = _dataStore; + if (store != null) { final book = store.getBook(bookId); - if (book == null) return; + + if (book == null) { + return; + } + await store.updateBookAndWait(book.copyWith(isFavorite: !currentFavorite), previous: book); return; } @@ -345,6 +378,7 @@ class LibraryProvider extends ChangeNotifier { /// Get the effective favorite status for a book. bool? getFavoriteOverride(String bookId) { final favoriteDelegate = _favoriteDelegate; + if (favoriteDelegate != null) { return favoriteDelegate.getFavoriteOverride(bookId); } @@ -352,15 +386,15 @@ class LibraryProvider extends ChangeNotifier { return _favoriteOverrides[bookId]; } - // =========================================================================== - // Selection mode - // =========================================================================== - /// Enter selection mode, optionally pre-selecting a book. void enterSelectionMode([String? initialBookId]) { _isSelectionMode = true; _selectedBookIds.clear(); - if (initialBookId != null) _selectedBookIds.add(initialBookId); + + if (initialBookId != null) { + _selectedBookIds.add(initialBookId); + } + notifyListeners(); } @@ -375,10 +409,14 @@ class LibraryProvider extends ChangeNotifier { void toggleBookSelection(String bookId) { if (_selectedBookIds.contains(bookId)) { _selectedBookIds.remove(bookId); - if (_selectedBookIds.isEmpty) exitSelectionMode(); + + if (_selectedBookIds.isEmpty) { + exitSelectionMode(); + } } else { _selectedBookIds.add(bookId); } + notifyListeners(); } diff --git a/app/lib/providers/notes_provider.dart b/app/lib/providers/notes_provider.dart index 2732e9da..d0e9e155 100644 --- a/app/lib/providers/notes_provider.dart +++ b/app/lib/providers/notes_provider.dart @@ -38,10 +38,6 @@ class NotesProvider extends ChangeNotifier { super.dispose(); } - // ============================================================================ - // GETTERS - // ============================================================================ - NoteSortOption get sortOption => _sortOption; Set get activeTags => _selectedTags; String get searchQuery => _searchQuery; @@ -60,17 +56,25 @@ class NotesProvider extends ChangeNotifier { /// All unique tags across all notes (for filter chip generation). Set get allTags { - if (_dataStore == null) return {}; + if (_dataStore == null) { + return {}; + } + final tags = {}; + for (final note in _dataStore!.notes) { tags.addAll(note.tags); } + return tags; } /// All notes, filtered and sorted. List get notes { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + var list = List.from(_dataStore!.notes); list = _applyFilters(list); _applySorting(list); @@ -81,9 +85,11 @@ class NotesProvider extends ChangeNotifier { Map> get notesByBook { final filtered = notes; final map = >{}; + for (final note in filtered) { map.putIfAbsent(note.bookId, () => []).add(note); } + return map; } @@ -101,10 +107,6 @@ class NotesProvider extends ChangeNotifier { return _dataStore?.getBook(bookId)?.coverMediaId; } - // ============================================================================ - // SORTING & FILTERING - // ============================================================================ - void setSortOption(NoteSortOption option) { _sortOption = option; notifyListeners(); @@ -112,11 +114,13 @@ class NotesProvider extends ChangeNotifier { void toggleTagFilter(String tag) { _selectedTags = Set.from(_selectedTags); + if (_selectedTags.contains(tag)) { _selectedTags.remove(tag); } else { _selectedTags.add(tag); } + notifyListeners(); } @@ -141,10 +145,6 @@ class NotesProvider extends ChangeNotifier { notifyListeners(); } - // ============================================================================ - // CRUD (delegated to DataStore) - // ============================================================================ - Future updateNote(Note note, {Note? previous, EntityRepository? repository}) async { await _dataStore?.updateNote(note, previous: previous, repository: repository); } @@ -155,28 +155,29 @@ class NotesProvider extends ChangeNotifier { Future togglePin(String noteId) async { final note = _dataStore?.getNote(noteId); - if (note == null || _dataStore == null) return; + + if (note == null || _dataStore == null) { + return; + } + await _dataStore!.updateNote(note.copyWith(isPinned: !note.isPinned), previous: note); } - // ============================================================================ - // PRIVATE HELPERS - // ============================================================================ - List _applyFilters(List all) { var result = all; if (_selectedTags.isNotEmpty) { - result = result.where((n) => n.tags.any((t) => _selectedTags.contains(t))).toList(); + result = result.where((note) => note.tags.any((value) => _selectedTags.contains(value))).toList(); } if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); - result = result.where((n) { - final bookTitle = getBookTitle(n.bookId).toLowerCase(); - final title = n.title.toLowerCase(); - final content = n.content.toLowerCase(); - final tags = n.tags.join(' ').toLowerCase(); + + result = result.where((note) { + final bookTitle = getBookTitle(note.bookId).toLowerCase(); + final title = note.title.toLowerCase(); + final content = note.content.toLowerCase(); + final tags = note.tags.join(' ').toLowerCase(); return bookTitle.contains(query) || title.contains(query) || content.contains(query) || tags.contains(query); }).toList(); } @@ -185,19 +186,19 @@ class NotesProvider extends ChangeNotifier { } void _applySorting(List list) { - list.sort((a, b) { + list.sort((left, right) { switch (_sortOption) { case NoteSortOption.dateNewest: - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); case NoteSortOption.dateOldest: - return a.createdAt.compareTo(b.createdAt); + return left.createdAt.compareTo(right.createdAt); case NoteSortOption.bookTitle: - return getBookTitle(a.bookId).toLowerCase().compareTo(getBookTitle(b.bookId).toLowerCase()); + return getBookTitle(left.bookId).toLowerCase().compareTo(getBookTitle(right.bookId).toLowerCase()); case NoteSortOption.pinnedFirst: - if (a.isPinned != b.isPinned) { - return a.isPinned ? -1 : 1; + if (left.isPinned != right.isPinned) { + return left.isPinned ? -1 : 1; } - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); } }); } diff --git a/app/lib/providers/preferences_provider.dart b/app/lib/providers/preferences_provider.dart index 49e78b57..c0963645 100644 --- a/app/lib/providers/preferences_provider.dart +++ b/app/lib/providers/preferences_provider.dart @@ -150,7 +150,11 @@ class PreferencesProvider extends ChangeNotifier { set gridItemWidth(double value) { final normalized = BookGridSize.normalize(value); - if (gridItemWidth == normalized) return; + + if (gridItemWidth == normalized) { + return; + } + _prefs.setDouble(_keyGridItemWidth, normalized); notifyListeners(); } diff --git a/app/lib/providers/shelves_provider.dart b/app/lib/providers/shelves_provider.dart index 98200801..775401b3 100644 --- a/app/lib/providers/shelves_provider.dart +++ b/app/lib/providers/shelves_provider.dart @@ -55,6 +55,7 @@ class ShelvesProvider extends ChangeNotifier { if (_selectedShelf != null) { _selectedShelf = _dataStore?.getShelf(_selectedShelf!.id); } + notifyListeners(); } @@ -64,10 +65,6 @@ class ShelvesProvider extends ChangeNotifier { super.dispose(); } - // ============================================================================ - // GETTERS - // ============================================================================ - bool get isLoading => _isLoading; String? get error => _error; @@ -78,7 +75,11 @@ class ShelvesProvider extends ChangeNotifier { void setGridItemWidth(double width) { final normalized = BookGridSize.normalize(width); - if (_gridItemWidth == normalized) return; + + if (_gridItemWidth == normalized) { + return; + } + _gridItemWidth = normalized; notifyListeners(); } @@ -94,10 +95,15 @@ class ShelvesProvider extends ChangeNotifier { /// Get all shelves, filtered and sorted according to current settings. List get shelves { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + var list = List.from(_dataStore!.shelves); + if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); + list = list.where((shelf) { return shelf.name.toLowerCase().contains(query) || (shelf.description?.toLowerCase().contains(query) ?? false); }).toList(); @@ -137,14 +143,13 @@ class ShelvesProvider extends ChangeNotifier { /// Get total book count across all shelves. int get totalBookCount { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + return _dataStore!.shelves.fold(0, (sum, shelf) => sum + _dataStore!.getBookCountForShelf(shelf.id)); } - // ============================================================================ - // METHODS - // ============================================================================ - /// Loads all shelves data. With DataStore, this is instant since data is already loaded. Future loadShelves() async { _isLoading = true; @@ -154,11 +159,10 @@ class ShelvesProvider extends ChangeNotifier { try { // Simulate network delay for realistic UX await Future.delayed(const Duration(milliseconds: 100)); - _isLoading = false; notifyListeners(); - } catch (e) { - _error = 'Failed to load shelves: $e'; + } catch (error) { + _error = 'Failed to load shelves: $error'; _isLoading = false; notifyListeners(); } @@ -206,7 +210,9 @@ class ShelvesProvider extends ChangeNotifier { /// Sets the shelf sort option and its explicit direction. void setShelfSortOption(ShelfSortOption option, {required bool ascending}) { - if (_shelfSortOption == option && _shelfSortAscending == ascending) return; + if (_shelfSortOption == option && _shelfSortAscending == ascending) { + return; + } _shelfSortOption = option; _shelfSortAscending = ascending; @@ -225,27 +231,32 @@ class ShelvesProvider extends ChangeNotifier { /// Applies the current sorting to a shelves list. void _applySorting(List list) { - list.sort((a, b) { + list.sort((left, right) { int result; + switch (_shelfSortOption) { case ShelfSortOption.name: - result = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + result = left.name.toLowerCase().compareTo(right.name.toLowerCase()); case ShelfSortOption.bookCount: - final aCount = _dataStore?.getBookCountForShelf(a.id) ?? 0; - final bCount = _dataStore?.getBookCountForShelf(b.id) ?? 0; + final aCount = _dataStore?.getBookCountForShelf(left.id) ?? 0; + final bCount = _dataStore?.getBookCountForShelf(right.id) ?? 0; result = aCount.compareTo(bCount); case ShelfSortOption.dateCreated: - result = a.createdAt.compareTo(b.createdAt); + result = left.createdAt.compareTo(right.createdAt); case ShelfSortOption.dateModified: - result = a.updatedAt.compareTo(b.updatedAt); + result = left.updatedAt.compareTo(right.updatedAt); } + return _shelfSortAscending ? result : -result; }); } /// Gets child shelves of a parent shelf. List getChildShelves(String parentShelfId) { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + return _dataStore!.getChildShelves(parentShelfId); } @@ -268,6 +279,7 @@ class ShelvesProvider extends ChangeNotifier { } final now = DateTime.now(); + final newShelf = Shelf( id: const Uuid().v4(), name: name, @@ -299,6 +311,7 @@ class ShelvesProvider extends ChangeNotifier { } final shelf = previous ?? _dataStore!.getShelf(shelfId); + if (shelf == null) { throw Exception('Shelf not found'); } @@ -327,6 +340,7 @@ class ShelvesProvider extends ChangeNotifier { } final shelf = _dataStore!.getShelf(shelfId); + if (shelf == null) { throw Exception('Shelf not found'); } @@ -349,6 +363,7 @@ class ShelvesProvider extends ChangeNotifier { // Update the shelf's updatedAt timestamp final shelf = _dataStore!.getShelf(shelfId); + if (shelf != null) { await _dataStore!.updateShelf(shelf.copyWith(updatedAt: DateTime.now()), previous: shelf); } @@ -364,6 +379,7 @@ class ShelvesProvider extends ChangeNotifier { // Update the shelf's updatedAt timestamp final shelf = _dataStore!.getShelf(shelfId); + if (shelf != null) { await _dataStore!.updateShelf(shelf.copyWith(updatedAt: DateTime.now()), previous: shelf); } @@ -371,14 +387,17 @@ class ShelvesProvider extends ChangeNotifier { /// Reorders shelves (drag and drop). Future reorderShelves(int oldIndex, int newIndex) async { - if (_dataStore == null) return; + if (_dataStore == null) { + return; + } final shelfList = List.from(_dataStore!.shelves); - shelfList.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); + shelfList.sort((left, right) => left.sortOrder.compareTo(right.sortOrder)); if (oldIndex < newIndex) { newIndex -= 1; } + final shelf = shelfList.removeAt(oldIndex); shelfList.insert(newIndex, shelf); @@ -390,13 +409,19 @@ class ShelvesProvider extends ChangeNotifier { /// Get book count for a specific shelf. int getBookCountForShelf(String shelfId) { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + return _dataStore!.getBookCountForShelf(shelfId); } /// Get cover previews for a shelf. List getCoverPreviewsForShelf(String shelfId) { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + return _dataStore!.getCoverPreviewsForShelf(shelfId); } diff --git a/app/lib/providers/sidebar_provider.dart b/app/lib/providers/sidebar_provider.dart index 6e45ed72..0790ff7d 100644 --- a/app/lib/providers/sidebar_provider.dart +++ b/app/lib/providers/sidebar_provider.dart @@ -18,9 +18,11 @@ class SidebarProvider extends ChangeNotifier { void toggleCollapsed() { _isCollapsed = !_isCollapsed; // When collapsing, also collapse library submenu + if (_isCollapsed) { _isLibraryExpanded = false; } + notifyListeners(); } @@ -28,9 +30,11 @@ class SidebarProvider extends ChangeNotifier { void setCollapsed(bool collapsed) { if (_isCollapsed != collapsed) { _isCollapsed = collapsed; + if (_isCollapsed) { _isLibraryExpanded = false; } + notifyListeners(); } } diff --git a/app/lib/providers/statistics_provider.dart b/app/lib/providers/statistics_provider.dart index 2f082ffe..6acf3498 100644 --- a/app/lib/providers/statistics_provider.dart +++ b/app/lib/providers/statistics_provider.dart @@ -47,6 +47,7 @@ class MonthlyStats { 'November', 'December', ]; + return months[month - 1]; } } @@ -68,10 +69,18 @@ class SessionStats { /// Formatted average session duration. String get averageSessionLabel { final avg = averageSessionDuration.round(); - if (avg < 60) return '${avg}m'; + + if (avg < 60) { + return '${avg}m'; + } + final hours = avg ~/ 60; final minutes = avg % 60; - if (minutes == 0) return '${hours}h'; + + if (minutes == 0) { + return '${hours}h'; + } + return '${hours}h ${minutes}m'; } @@ -124,10 +133,6 @@ class StatisticsProvider extends ChangeNotifier { super.dispose(); } - // ============================================================================ - // GETTERS - // ============================================================================ - bool get isLoading => _isLoading; String? get error => _error; @@ -137,15 +142,26 @@ class StatisticsProvider extends ChangeNotifier { /// Total books completed in the selected period. int get totalBooks { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + final range = _getDateRangeForPeriod(); 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(); + + if (store == null) { + return {}; + } + + final trackedBooks = store.readingActivities + .where((activity) => activity.kind == 'completion') + .map((activity) => activity.bookId) + .toSet(); + return { for (final activity in store.effectiveReadingActivities) if (activity.kind == 'completion' && !activity.endTime.isBefore(start) && activity.endTime.isBefore(end)) @@ -162,13 +178,18 @@ class StatisticsProvider extends ChangeNotifier { /// Goals completed in the selected period. int get goalsCompleted { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + final range = _getDateRangeForPeriod(); 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( @@ -178,6 +199,7 @@ class StatisticsProvider extends ChangeNotifier { ); } } + return periods.values .where( (goal) => @@ -190,14 +212,20 @@ class StatisticsProvider extends ChangeNotifier { /// Total reading minutes in the selected period. int get totalReadingMinutes { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + final range = _getDateRangeForPeriod(); return _dataStore!.activityTotals(range.start, range.end).seconds ~/ 60; } /// Pages read in the selected period. int get pagesRead { - if (_dataStore == null) return 0; + if (_dataStore == null) { + return 0; + } + final range = _getDateRangeForPeriod(); return _dataStore!.activityTotals(range.start, range.end).pages.floor(); } @@ -207,12 +235,18 @@ class StatisticsProvider extends ChangeNotifier { if (_dataStore == null) { return const SessionStats(totalSessions: 0, totalMinutes: 0, totalPages: 0); } + final range = _getDateRangeForPeriod(); + final sessions = groupReadingActivities( _dataStore!.effectiveReadingActivities.where( - (s) => s.kind == 'reading' && s.endTime.isAfter(range.start) && s.startTime.isBefore(range.end), + (activity) => + activity.kind == 'reading' && + activity.endTime.isAfter(range.start) && + activity.startTime.isBefore(range.end), ), ); + return SessionStats(totalSessions: sessions.length, totalMinutes: totalReadingMinutes, totalPages: pagesRead); } @@ -222,30 +256,43 @@ class StatisticsProvider extends ChangeNotifier { List get genreDistribution => _genreDistribution; ReadingStreak get streak => _streak; - // ============================================================================ - // COMPUTED PROPERTIES - // ============================================================================ - /// Total reading time formatted (e.g., "3.5h"). String get totalReadingLabel { final hours = totalReadingMinutes / 60; - if (hours < 1) return '${totalReadingMinutes}m'; - if (hours == hours.truncate()) return '${hours.truncate()}h'; + + if (hours < 1) { + return '${totalReadingMinutes}m'; + } + + if (hours == hours.truncate()) { + return '${hours.truncate()}h'; + } + return '${hours.toStringAsFixed(1)}h'; } /// Average reading time per day for the period. String get averageReadingLabel { - if (_readingTimeData.isEmpty) return '0m'; + if (_readingTimeData.isEmpty) { + return '0m'; + } + final avgMinutes = _readingTimeData.averageMinutes; - if (avgMinutes < 60) return '${avgMinutes}m'; + + if (avgMinutes < 60) { + return '${avgMinutes}m'; + } + final hours = avgMinutes / 60; return '${hours.toStringAsFixed(1)}h'; } /// Average daily reading time in minutes. int get averageDailyMinutes { - if (_readingTimeData.isEmpty) return 0; + if (_readingTimeData.isEmpty) { + return 0; + } + return _readingTimeData.averageMinutes; } @@ -288,10 +335,6 @@ class StatisticsProvider extends ChangeNotifier { bool get hasCustomRange => _selectedPeriod == StatsPeriod.custom && _customStartDate != null && _customEndDate != null; - // ============================================================================ - // METHODS - // ============================================================================ - /// Loads statistics data. With DataStore, this is mainly for loading state UX. Future loadStatistics() async { _isLoading = true; @@ -301,13 +344,11 @@ class StatisticsProvider extends ChangeNotifier { try { // Simulate network delay for realistic UX await Future.delayed(const Duration(milliseconds: 100)); - _updateDataForPeriod(); - _isLoading = false; notifyListeners(); - } catch (e) { - _error = 'Failed to load statistics: $e'; + } catch (error) { + _error = 'Failed to load statistics: $error'; _isLoading = false; notifyListeners(); } @@ -317,10 +358,12 @@ class StatisticsProvider extends ChangeNotifier { void setPeriod(StatsPeriod period) { if (_selectedPeriod != period) { _selectedPeriod = period; + if (period != StatsPeriod.custom) { _customStartDate = null; _customEndDate = null; } + _updateDataForPeriod(); notifyListeners(); } @@ -340,12 +383,9 @@ class StatisticsProvider extends ChangeNotifier { await loadStatistics(); } - // ============================================================================ - // PRIVATE METHODS - // ============================================================================ - ({DateTime start, DateTime end}) _getDateRangeForPeriod() { final now = DateTime.now(); + switch (_selectedPeriod) { case StatsPeriod.week: final weekStart = DateTime(now.year, now.month, now.day - now.weekday + 1); @@ -373,57 +413,83 @@ class StatisticsProvider extends ChangeNotifier { void _updateDataForPeriod() { 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); + + 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); + + final count = genres.values.fold(0, (total, value) => total + value); + _genreDistribution = genres.entries - .map((e) => GenreStats(genre: e.key, bookCount: e.value, percentage: e.value / count)) + .map((entry) => GenreStats(genre: entry.key, bookCount: entry.value, percentage: entry.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; + + 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); + + 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); + daysThisMonth: qualified.where((date) { + final day = GoalCalendar.local(date, GoalCalendar.systemTimezone); return day.year == local.year && day.month == local.month; }).length, totalDaysInMonth: DateTime(local.year, local.month + 1, 0).day, ); + _monthlyStats = _generateMonthlyStats(); _readingTimeData = _generateActivityData(); _pagesReadData = _readingTimeData; } List _generateActivityData() { - if (_dataStore == null) return []; + if (_dataStore == null) { + return []; + } + final range = _getDateRangeForPeriod(); 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: day, readingMinutes: totals.seconds ~/ 60, @@ -435,10 +501,12 @@ class StatisticsProvider extends ChangeNotifier { List _generateMonthlyStats() { final now = DateTime.now(); + return List.generate(12, (i) { 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: start.month, year: start.year, diff --git a/app/lib/providers/sync_settings_provider.dart b/app/lib/providers/sync_settings_provider.dart index 34a30ddb..5a1f8571 100644 --- a/app/lib/providers/sync_settings_provider.dart +++ b/app/lib/providers/sync_settings_provider.dart @@ -27,13 +27,13 @@ class DataSyncDiscoverySettings { factory DataSyncDiscoverySettings.fromJson(Map json) { final dataSyncUrl = json['data_sync_url'] as String?; + if (dataSyncUrl == null || dataSyncUrl.trim().isEmpty) { throw const SyncSettingsException('Server did not provide data sync settings'); } final fileStorage = json['file_storage']; final quotaBytes = fileStorage is Map ? fileStorage['quota_bytes'] as int? : null; - return DataSyncDiscoverySettings(dataSyncUri: Uri.parse(dataSyncUrl), fileStorageQuotaBytes: quotaBytes); } } @@ -95,15 +95,20 @@ class SyncSettingsProvider extends ChangeNotifier { String get activeServerId { final id = _prefs.getString(_keyActiveServerId) ?? officialServerId; + if (id == officialServerId || customServers.any((server) => server.id == id)) { return id; } + return officialServerId; } List get customServers { final raw = _prefs.getString(_keyCustomServers); - if (raw == null || raw.isEmpty) return const []; + + if (raw == null || raw.isEmpty) { + return const []; + } final decoded = jsonDecode(raw) as List; return decoded.map((item) => CustomSyncServer.fromJson(item as Map)).toList(growable: false); @@ -111,10 +116,17 @@ class SyncSettingsProvider extends ChangeNotifier { CustomSyncServer? get activeCustomServer { final id = activeServerId; - if (id == officialServerId) return null; + + if (id == officialServerId) { + return null; + } + for (final server in customServers) { - if (server.id == id) return server; + if (server.id == id) { + return server; + } } + return null; } @@ -124,14 +136,20 @@ class SyncSettingsProvider extends ChangeNotifier { PapyrusApiConfig get activeApiConfig { final customServer = activeCustomServer; - if (customServer == null) return officialConfig; + + if (customServer == null) { + return officialConfig; + } return PapyrusApiConfig(serverBaseUri: Uri.parse(customServer.url), powerSyncServiceUri: customServer.dataSyncUri); } String get activeProfileKey { final customServer = activeCustomServer; - if (customServer == null) return officialServerId; + + if (customServer == null) { + return officialServerId; + } final digest = sha256.convert(utf8.encode(customServer.url)).toString(); return 'custom-${digest.substring(0, 16)}'; @@ -143,7 +161,10 @@ class SyncSettingsProvider extends ChangeNotifier { String fileStorageLabel({required int usedBytes, int? quotaBytesOverride}) { final quotaBytes = quotaBytesOverride ?? fileStorageQuotaBytes; - if (quotaBytes == null) return '${_formatBytes(usedBytes)} used'; + + if (quotaBytes == null) { + return '${_formatBytes(usedBytes)} used'; + } final availableBytes = quotaBytes > usedBytes ? quotaBytes - usedBytes : 0; return '${_formatBytes(usedBytes)} used, ${_formatBytes(availableBytes)} available of ${_formatBytes(quotaBytes)}'; @@ -155,6 +176,7 @@ class SyncSettingsProvider extends ChangeNotifier { final serverUri = _normalizeServerUri(url); final normalizedUrl = serverUri.toString(); final servers = customServers; + if (servers.any((server) => server.url == normalizedUrl)) { throw const SyncSettingsException('This custom server already exists'); } @@ -170,10 +192,14 @@ class SyncSettingsProvider extends ChangeNotifier { Future updateCustomServer(String id, String url) async { final servers = customServers; final index = servers.indexWhere((server) => server.id == id); - if (index == -1) throw const SyncSettingsException('Custom server was not found'); + + if (index == -1) { + throw const SyncSettingsException('Custom server was not found'); + } final serverUri = _normalizeServerUri(url); final normalizedUrl = serverUri.toString(); + if (servers.any((server) => server.id != id && server.url == normalizedUrl)) { throw const SyncSettingsException('This custom server already exists'); } @@ -182,7 +208,11 @@ class SyncSettingsProvider extends ChangeNotifier { final updated = _customServerFromDiscovery(serverUri, settings); final nextServers = [...servers]..[index] = updated; _saveCustomServers(nextServers); - if (activeServerId == id) _setActiveServerId(updated.id); + + if (activeServerId == id) { + _setActiveServerId(updated.id); + } + notifyListeners(); return updated; } @@ -198,12 +228,17 @@ class SyncSettingsProvider extends ChangeNotifier { void removeCustomServer(String id) { final nextServers = customServers.where((server) => server.id != id).toList(growable: false); + if (nextServers.length == customServers.length) { throw const SyncSettingsException('Custom server was not found'); } _saveCustomServers(nextServers); - if (activeServerId == id) _setActiveServerId(officialServerId); + + if (activeServerId == id) { + _setActiveServerId(officialServerId); + } + notifyListeners(); } @@ -216,9 +251,11 @@ class SyncSettingsProvider extends ChangeNotifier { selectServer(officialServerId); return; } + if (customServers.isEmpty) { throw const SyncSettingsException('Custom server was not found'); } + selectServer(customServers.first.id); } @@ -232,6 +269,7 @@ class SyncSettingsProvider extends ChangeNotifier { void setCustomServerUrls({required String apiUrl, required String powerSyncUrl}) { final serverUri = _normalizeServerUri(apiUrl); final dataSyncUri = _normalizeServerUri(powerSyncUrl); + final server = CustomSyncServer( id: _customServerId(serverUri.toString()), url: serverUri.toString(), @@ -239,22 +277,27 @@ class SyncSettingsProvider extends ChangeNotifier { dataSyncUri: dataSyncUri, fileStorageQuotaBytes: null, ); + _saveCustomServers([server]); _setActiveServerId(server.id); notifyListeners(); } void _migrateLegacyCustomServer() { - if (customServers.isNotEmpty) return; + if (customServers.isNotEmpty) { + return; + } final legacyApiUrl = _prefs.getString(_legacyKeyCustomApiUrl); final legacyDataSyncUrl = _prefs.getString(_legacyKeyCustomPowerSyncUrl); + if (legacyApiUrl == null || legacyApiUrl.isEmpty || legacyDataSyncUrl == null || legacyDataSyncUrl.isEmpty) { return; } final serverUri = _normalizeServerUri(legacyApiUrl); final dataSyncUri = _normalizeServerUri(legacyDataSyncUrl); + final server = CustomSyncServer( id: _customServerId(serverUri.toString()), url: serverUri.toString(), @@ -264,6 +307,7 @@ class SyncSettingsProvider extends ChangeNotifier { ); _saveCustomServers([server]); + if (_prefs.getString(_legacyKeyServerType) == SyncServerType.custom.name) { _setActiveServerId(server.id); } @@ -271,6 +315,7 @@ class SyncSettingsProvider extends ChangeNotifier { CustomSyncServer _customServerFromDiscovery(Uri serverUri, DataSyncDiscoverySettings settings) { final normalizedUrl = serverUri.toString(); + return CustomSyncServer( id: _customServerId(normalizedUrl), url: normalizedUrl, @@ -290,15 +335,21 @@ class SyncSettingsProvider extends ChangeNotifier { static Future _fetchDataSyncSettings(Uri serverUrl) async { final client = http.Client(); + try { final config = PapyrusApiConfig(serverBaseUri: serverUrl); final response = await client.get(config.endpoint('/sync/settings'), headers: {'Accept': 'application/json'}); + if (response.statusCode < 200 || response.statusCode >= 300) { throw const SyncSettingsException('Server settings could not be loaded'); } + return DataSyncDiscoverySettings.fromJson(jsonDecode(response.body) as Map); } catch (error) { - if (error is SyncSettingsException) rethrow; + if (error is SyncSettingsException) { + rethrow; + } + throw const SyncSettingsException('Server settings could not be loaded'); } finally { client.close(); @@ -332,14 +383,26 @@ class SyncSettingsProvider extends ChangeNotifier { const mib = 1024 * 1024; const gib = 1024 * mib; - if (bytes >= gib) return '${_formatByteValue(bytes / gib)} GB'; - if (bytes == 0) return '0 MB'; + if (bytes >= gib) { + return '${_formatByteValue(bytes / gib)} GB'; + } + + if (bytes == 0) { + return '0 MB'; + } + return '${_formatByteValue(bytes / mib)} MB'; } String _formatByteValue(double value) { - if (value == value.roundToDouble()) return value.round().toString(); - if (value >= 10) return value.toStringAsFixed(1); + if (value == value.roundToDouble()) { + return value.round().toString(); + } + + if (value >= 10) { + return value.toStringAsFixed(1); + } + return value.toStringAsFixed(2); } } diff --git a/app/lib/reader/reader_book_adapter.dart b/app/lib/reader/reader_book_adapter.dart index f16ba620..ad9f8756 100644 --- a/app/lib/reader/reader_book_adapter.dart +++ b/app/lib/reader/reader_book_adapter.dart @@ -19,14 +19,19 @@ final class ReaderBookAdapter { static ReaderLocator? restoreLocator(Book book) { final stored = book.customMetadata?[locatorMetadataKey]; - if (stored is! Map) return null; + + if (stored is! Map) { + return null; + } try { final locator = ReaderLocator.fromJson(stored.map((key, value) => MapEntry(key.toString(), value))); final format = formatFor(book.fileFormat); + if (format == ReaderFormat.epub && locator is! EpubReaderLocator) { return null; } + if (format == ReaderFormat.pdf && locator is! PdfReaderLocator) { return null; } @@ -40,16 +45,18 @@ final class ReaderBookAdapter { static Book applyLocator(Book book, ReaderLocator locator, {required DateTime now}) { final metadata = Map.from(book.customMetadata ?? const {}); metadata[locatorMetadataKey] = locator.toJson(); - final position = _totalProgression(locator); + final page = switch (locator) { EpubReaderLocator() => book.currentPage, PdfReaderLocator(:final pageIndex) => pageIndex + 1, }; + final cfi = switch (locator) { EpubReaderLocator(:final cfi) => cfi, PdfReaderLocator() => null, }; + final status = book.readingStatus == LibraryReadingStatus.unread && position > 0 ? LibraryReadingStatus.inProgress : book.readingStatus; @@ -89,9 +96,11 @@ final class ReaderBookAdapter { static void persistPreferences(PreferencesProvider target, ReaderPreferences preferences) { final fontFamily = preferences.fontFamily; + if (fontFamily != null && target.defaultFont != fontFamily) { target.defaultFont = fontFamily; } + if (target.defaultFontSize != preferences.fontSize) { target.defaultFontSize = preferences.fontSize; } @@ -101,21 +110,25 @@ final class ReaderBookAdapter { > 1.6 => 'relaxed', _ => 'normal', }; + if (target.lineSpacing != lineSpacing) { target.lineSpacing = lineSpacing; } final margin = preferences.pageMargins.left; + final margins = switch (margin) { < 20 => 'small', > 32 => 'large', _ => 'medium', }; + if (target.margins != margins) { target.margins = margins; } final readingMode = preferences.layoutMode == ReaderLayoutMode.scroll ? 'scroll' : 'paginated'; + if (target.readingMode != readingMode) { target.readingMode = readingMode; } diff --git a/app/lib/reader/reader_session.dart b/app/lib/reader/reader_session.dart index 8566a708..0e4dcd0f 100644 --- a/app/lib/reader/reader_session.dart +++ b/app/lib/reader/reader_session.dart @@ -30,7 +30,9 @@ final class ReaderSession { bool _disposed = false; void updateLocator(ReaderLocator locator) { - if (_disposed) return; + if (_disposed) { + return; + } _pendingLocator = locator; tracker?.updateLocator(locator); @@ -40,6 +42,7 @@ final class ReaderSession { Future flush() async { final locator = _pendingLocator; + if (locator == null) { await tracker?.flush(); return; @@ -48,16 +51,20 @@ final class ReaderSession { _timer?.cancel(); _timer = null; _pendingLocator = null; + if (tracker != null) { await tracker!.flush(); return; } + _book = ReaderBookAdapter.applyLocator(_book, locator, now: _now()); _saveBook(_book); } void dispose() { - if (_disposed) return; + if (_disposed) { + return; + } unawaited(flush()); unawaited(tracker?.close().catchError((Object error) => tracker!.onError(error))); diff --git a/app/lib/reader/reading_activity_tracker.dart b/app/lib/reader/reading_activity_tracker.dart index 5ebb541c..3b87ed58 100644 --- a/app/lib/reader/reading_activity_tracker.dart +++ b/app/lib/reader/reading_activity_tracker.dart @@ -21,6 +21,7 @@ class ReadingActivityTracker { shelfIds = List.of(shelfIds) { _timer = Timer.periodic(checkpointInterval, (_) => unawaited(flush())); } + final TrackingRepository repository; final Book book; List shelfIds; @@ -41,18 +42,27 @@ class ReadingActivityTracker { 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 key = jsonEncode(event.coverage.map((item) => [item.key, item.start, item.end, item.chapterCount]).toList()); final changed = key != _coverageKey; - if (changed || wasActive && (!event.ready || !event.visible)) _capture(now); + + if (changed || wasActive && (!event.ready || !event.visible)) { + _capture(now); + } + _event = event; _coverageKey = key; - if (changed) _exposedSince = null; + + if (changed) { + _exposedSince = null; + } + if (!_active) { _started = null; _exposedSince = null; @@ -60,19 +70,31 @@ class ReadingActivityTracker { _started ??= now; _exposedSince ??= now; } - if (event.locator != null) updateLocator(event.locator!); - if (changed || wasActive != _active) unawaited(_drain()); + + 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; + 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; + if (_foreground == foreground) { + return; + } + _capture(_now().toUtc()); _foreground = foreground; _started = _active ? _now().toUtc() : null; @@ -82,10 +104,12 @@ class ReadingActivityTracker { 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, @@ -97,14 +121,26 @@ class ReadingActivityTracker { void _capture(DateTime now) { final start = _started; - if (start == null || !_active || !now.isAfter(start)) return; + + 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; + if (extent.end <= extent.start) { + continue; + } + final chapterCount = extent.chapterCount; final pageCount = book.pageCount; - if (chapterCount != null && (pageCount == null || pageCount <= 0)) continue; + + if (chapterCount != null && (pageCount == null || pageCount <= 0)) { + continue; + } + coverage.add( PageCoverage( key: '${book.id}:${extent.key}', @@ -116,6 +152,7 @@ class ReadingActivityTracker { ); } } + _pending.add( ReadingActivity( id: const Uuid().v4(), @@ -131,9 +168,13 @@ class ReadingActivityTracker { coverage: coverage, ), ); + _started = now; final locator = _locator; - if (locator != null) updateLocator(locator); + + if (locator != null) { + updateLocator(locator); + } } Future flush() { @@ -143,21 +184,30 @@ class ReadingActivityTracker { Future _drain() { final operation = _writes.then((_) async { - if (!repository.isCurrent || _pending.isEmpty && _pendingPatch == null) return; + 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; + + if (identical(_pendingPatch, patch)) { + _pendingPatch = null; + } } catch (error) { onError(error); } }); + _writes = operation; return operation; } @@ -167,6 +217,7 @@ class ReadingActivityTracker { _capture(_now().toUtc()); _disposed = true; await _drain(); + if (repository.isCurrent && (_pending.isNotEmpty || _pendingPatch != null)) { _disposed = false; _started = _active ? _now().toUtc() : null; diff --git a/app/lib/services/book_delete_cleanup_service.dart b/app/lib/services/book_delete_cleanup_service.dart index 3f35f71b..f944a5e3 100644 --- a/app/lib/services/book_delete_cleanup_service.dart +++ b/app/lib/services/book_delete_cleanup_service.dart @@ -17,15 +17,19 @@ Future deleteBookWithMediaCleanup({ }) async { await mediaUploadQueue.removeTasksForBook(bookId); await _bestEffort(() => deleteBookFile(bookId)); + if (deletePendingCover != null) { await _bestEffort(() => deletePendingCover(bookId)); } + if (deleteGuestCover != null) { await _bestEffort(() => deleteGuestCover(bookId)); } + if (coverMediaId != null && deleteCoverFile != null) { await _bestEffort(() => deleteCoverFile(coverMediaId)); } + dataStore.deleteBook(bookId); } diff --git a/app/lib/services/book_download_service.dart b/app/lib/services/book_download_service.dart index cf23d580..cd3b126e 100644 --- a/app/lib/services/book_download_service.dart +++ b/app/lib/services/book_download_service.dart @@ -15,6 +15,7 @@ class BookDownloadService { if (path == null) { return const BookDownloadResult.cancelled(); } + return BookDownloadResult.saved(path); } diff --git a/app/lib/services/book_download_service_platform_io.dart b/app/lib/services/book_download_service_platform_io.dart index 4c1fb185..699322d0 100644 --- a/app/lib/services/book_download_service_platform_io.dart +++ b/app/lib/services/book_download_service_platform_io.dart @@ -16,10 +16,13 @@ Future saveBookFileToDevice({ bytes: Platform.isAndroid || Platform.isIOS ? bytes : null, ); - if (path == null) return null; + if (path == null) { + return null; + } if (!Platform.isAndroid && !Platform.isIOS) { await File(path).writeAsBytes(bytes); } + return path; } diff --git a/app/lib/services/book_download_service_platform_web.dart b/app/lib/services/book_download_service_platform_web.dart index d05baafa..30638f8e 100644 --- a/app/lib/services/book_download_service_platform_web.dart +++ b/app/lib/services/book_download_service_platform_web.dart @@ -10,6 +10,7 @@ Future saveBookFileToDevice({ }) async { final blob = Blob([bytes.toJS].toJS, BlobPropertyBag(type: _contentTypeForExtension(extension))); final url = URL.createObjectURL(blob); + final anchor = HTMLAnchorElement() ..href = url ..download = fileName @@ -19,7 +20,6 @@ Future saveBookFileToDevice({ anchor.click(); anchor.remove(); URL.revokeObjectURL(url); - return fileName; } diff --git a/app/lib/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart index 0fe00956..d6c4e5a1 100644 --- a/app/lib/services/book_import_commit_service.dart +++ b/app/lib/services/book_import_commit_service.dart @@ -82,6 +82,7 @@ class BookImportCommitService { final coverImage = result.coverImage; var coverStored = false; var metadataAdded = false; + try { if (coverImage != null) { if (accountScope != null) { @@ -89,11 +90,11 @@ class BookImportCommitService { } else { await _storeGuestCover(book.id, coverImage); } + coverStored = true; } _ensureLibraryContextCurrent(); - await _addBook(book); metadataAdded = true; _ensureLibraryContextCurrent(); @@ -114,6 +115,7 @@ class BookImportCommitService { if (metadataAdded) { await _bestEffort(() => _deleteBook(book.id)); } + if (coverStored) { if (accountScope != null) { await _bestEffort(() => _deletePendingCover(accountScope, book.id)); @@ -121,6 +123,7 @@ class BookImportCommitService { await _bestEffort(() => _deleteGuestCover(book.id)); } } + Error.throwWithStackTrace(error, stackTrace); } } @@ -144,8 +147,11 @@ Future _bestEffort(FutureOr Function() compensation) async { BookFormat? _bookFormat(String extension) { for (final format in BookFormat.values) { - if (format.name == extension) return format; + if (format.name == extension) { + return format; + } } + return null; } diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 6f5e2df6..d1ba7365 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -29,7 +29,9 @@ class BookImportService { /// The single `onmessage` handler is registered here so concurrent calls /// do not overwrite each other's handler. web.Worker _getWorker() { - if (_worker != null) return _worker!; + if (_worker != null) { + return _worker!; + } final worker = web.Worker('book_worker.js'.toJS); @@ -40,9 +42,13 @@ class BookImportService { 'error', ((web.Event _) { final error = Exception('Worker error'); + for (final c in _pending.values) { - if (!c.isCompleted) c.completeError(error); + if (!c.isCompleted) { + c.completeError(error); + } } + _pending.clear(); }).toJS, ); @@ -52,11 +58,16 @@ class BookImportService { ((web.Event e) { final event = e as web.MessageEvent; final data = event.data; + if (data == null || data.isNull || data.isUndefined) { final error = Exception('Worker returned null data'); + for (final c in _pending.values) { - if (!c.isCompleted) c.completeError(error); + if (!c.isCompleted) { + c.completeError(error); + } } + _pending.clear(); return; } @@ -70,24 +81,32 @@ class BookImportService { final action = _jsToNullableString(obj['action']); final requestId = _jsToNullableString(obj['requestId']); final bookId = _jsToNullableString(obj['bookId']); + if (requestId != null) { final c = _pending.remove(requestId); + if (c != null && !c.isCompleted) { c.completeError(error); return; } } + if (action != null && bookId != null) { final key = '$action:$bookId'; final c = _pending.remove(key); + if (c != null && !c.isCompleted) { c.completeError(error); return; } } + for (final c in _pending.values) { - if (!c.isCompleted) c.completeError(error); + if (!c.isCompleted) { + c.completeError(error); + } } + _pending.clear(); return; } @@ -97,15 +116,18 @@ class BookImportService { final requestId = _jsToNullableString(obj['requestId']); final bookId = _jsToNullableString(obj['bookId']); final key = requestId ?? (action != null && bookId != null ? '$action:$bookId' : null); + if (key == null) { debugPrint( 'BookImportService: success message with null ' 'action=$action bookId=$bookId requestId=$requestId — ignoring', ); + return; } final c = _pending.remove(key); + if (c != null && !c.isCompleted) { c.complete(obj); } @@ -128,6 +150,7 @@ class BookImportService { } final ext = filename.toLowerCase().split('.').last; + if (ext != 'epub') { throw ArgumentError('Unsupported format: $ext. Only epub is supported.'); } @@ -135,7 +158,6 @@ class BookImportService { final bookId = const Uuid().v4(); final completer = Completer(); final worker = _getWorker(); - _pending['process:$bookId'] = completer; // Transfer bytes as ArrayBuffer for zero-copy transfer. @@ -143,13 +165,13 @@ class BookImportService { final actualBytes = bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes ? bytes : Uint8List.fromList(bytes); + final jsBuffer = actualBytes.buffer.toJS; final message = JSObject(); message['type'] = 'process'.toJS; message['format'] = ext.toJS; message['bookId'] = bookId.toJS; message['fileData'] = jsBuffer; - worker.postMessage(message, [jsBuffer].toJS); final obj = await completer.future.timeout( @@ -159,6 +181,7 @@ class BookImportService { throw TimeoutException('Book import timed out after ${_timeout.inSeconds}s', _timeout); }, ); + return _parseImportResult(obj, bookId, ext); } @@ -172,13 +195,10 @@ class BookImportService { final completer = Completer(); final worker = _getWorker(); - _pending['delete:$bookId'] = completer; - final message = JSObject(); message['type'] = 'delete'.toJS; message['bookId'] = bookId.toJS; - worker.postMessage(message); await completer.future.timeout( @@ -201,13 +221,10 @@ class BookImportService { final completer = Completer(); final worker = _getWorker(); - _pending['getFile:$bookId'] = completer; - final message = JSObject(); message['type'] = 'getFile'.toJS; message['bookId'] = bookId.toJS; - worker.postMessage(message); final obj = await completer.future.timeout( @@ -217,10 +234,13 @@ class BookImportService { throw TimeoutException('Get file timed out after ${_timeout.inSeconds}s', _timeout); }, ); + final fileDataJs = obj['fileData']; + if (fileDataJs == null || fileDataJs.isNull || fileDataJs.isUndefined) { return null; } + return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); } @@ -234,7 +254,6 @@ class BookImportService { final completer = Completer(); final worker = _getWorker(); _pending['hasFile:$bookId'] = completer; - final message = JSObject(); message['type'] = 'hasFile'.toJS; message['bookId'] = bookId.toJS; @@ -247,6 +266,7 @@ class BookImportService { throw TimeoutException('File check timed out after ${_timeout.inSeconds}s', _timeout); }, ); + return (obj['exists'] as JSBoolean).toDart; } @@ -259,25 +279,25 @@ class BookImportService { } final normalizedExtension = extension.toLowerCase().replaceFirst('.', ''); + if (normalizedExtension.isEmpty) { throw ArgumentError('Book file extension cannot be empty.'); } final completer = Completer(); final worker = _getWorker(); - _pending['storeFile:$bookId'] = completer; final actualBytes = bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes ? bytes : Uint8List.fromList(bytes); + final jsBuffer = actualBytes.buffer.toJS; final message = JSObject(); message['type'] = 'storeFile'.toJS; message['format'] = normalizedExtension.toJS; message['bookId'] = bookId.toJS; message['fileData'] = jsBuffer; - worker.postMessage(message, [jsBuffer].toJS); await completer.future.timeout( @@ -342,9 +362,11 @@ class BookImportService { Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { final obj = await _sendCoverRequest(type: 'getCover', scope: scope, bucket: bucket, mediaId: id); final fileDataJs = obj['fileData']; + if (fileDataJs == null || fileDataJs.isNull || fileDataJs.isUndefined) { return null; } + return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); } @@ -366,16 +388,16 @@ class BookImportService { _worker?.terminate(); _worker = null; final error = StateError('BookImportService was disposed'); + for (final c in _pending.values) { - if (!c.isCompleted) c.completeError(error); + if (!c.isCompleted) { + c.completeError(error); + } } + _pending.clear(); } - // --------------------------------------------------------------------------- - // Private helpers - // --------------------------------------------------------------------------- - Future _sendCoverRequest({ required String type, required MediaStorageScope scope, @@ -388,14 +410,19 @@ class BookImportService { final completer = Completer(); final worker = _getWorker(); _pending[requestId] = completer; - final message = JSObject(); message['type'] = type.toJS; message['requestId'] = requestId.toJS; message['scopeKey'] = scope.persistenceKey.toJS; message['bucket'] = bucket.pathComponent.toJS; - if (mediaId != null) message['mediaId'] = mediaId.toJS; - if (targetMediaId != null) message['targetMediaId'] = targetMediaId.toJS; + + if (mediaId != null) { + message['mediaId'] = mediaId.toJS; + } + + if (targetMediaId != null) { + message['targetMediaId'] = targetMediaId.toJS; + } if (bytes == null) { worker.postMessage(message); @@ -419,11 +446,12 @@ class BookImportService { BookImportResult _parseImportResult(JSObject data, String bookId, String fileExtension) { final metadataRaw = data['metadata']; + if (metadataRaw == null || metadataRaw.isNull || metadataRaw.isUndefined) { throw StateError('Worker response is missing required "metadata" field for book $bookId.'); } - final metadataJs = metadataRaw as JSObject; + final metadataJs = metadataRaw as JSObject; final title = _jsToNullableString(metadataJs['title']) ?? bookId; final subtitle = _jsToNullableString(metadataJs['subtitle']); final author = _jsToNullableString(metadataJs['author']) ?? ''; @@ -436,32 +464,41 @@ class BookImportService { // co-authors array final coAuthorsJs = metadataJs['coAuthors']; final coAuthors = []; + if (coAuthorsJs != null && !coAuthorsJs.isNull && !coAuthorsJs.isUndefined) { final arr = coAuthorsJs as JSArray; + for (var i = 0; i < arr.length; i++) { final item = _jsToNullableString(arr[i]); - if (item != null) coAuthors.add(item); + + if (item != null) { + coAuthors.add(item); + } } } // Cover image Uint8List? coverImage; final coverDataJs = data['coverData']; + if (coverDataJs != null && !coverDataJs.isNull && !coverDataJs.isUndefined) { coverImage = (coverDataJs as JSArrayBuffer).toDart.asUint8List(); } - final coverMimeType = _jsToNullableString(data['coverMimeType']); + final coverMimeType = _jsToNullableString(data['coverMimeType']); final fileSizeRaw = data['fileSize']; + if (fileSizeRaw == null || fileSizeRaw.isNull || fileSizeRaw.isUndefined) { throw StateError('Worker response missing "fileSize" for book $bookId'); } - final fileSize = (fileSizeRaw as JSNumber).toDartInt; + final fileSize = (fileSizeRaw as JSNumber).toDartInt; final fileHashRaw = _jsToNullableString(data['fileHash']); + if (fileHashRaw == null) { throw StateError('Worker response is missing required "fileHash" field for book $bookId.'); } + final fileHash = fileHashRaw; return BookImportResult( @@ -484,12 +521,18 @@ class BookImportService { } static String? _jsToNullableString(JSAny? value) { - if (value == null || value.isNull || value.isUndefined) return null; + if (value == null || value.isNull || value.isUndefined) { + return null; + } + return (value as JSString).toDart; } static int? _jsToNullableInt(JSAny? value) { - if (value == null || value.isNull || value.isUndefined) return null; + if (value == null || value.isNull || value.isUndefined) { + return null; + } + return (value as JSNumber).toDartInt; } } diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index c297e7e6..a7baafd9 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -30,6 +30,7 @@ class BookImportService { /// EPUB, PDF, MOBI, AZW3, CBZ, CBR, TXT. Future importBook(Uint8List bytes, String filename) async { final ext = p.extension(filename).toLowerCase().replaceFirst('.', ''); + if (ext.isEmpty) { throw ArgumentError('Filename has no extension: $filename'); } @@ -71,6 +72,7 @@ class BookImportService { Future deleteBookFile(String bookId) async { final booksDir = await _getBooksDirectory(); final files = booksDir.listSync(); + for (final entity in files) { if (entity is File && p.basenameWithoutExtension(entity.path) == bookId) { await entity.delete(); @@ -85,22 +87,26 @@ class BookImportService { Future getBookFile(String bookId) async { final booksDir = await _getBooksDirectory(); final files = booksDir.listSync(); + for (final entity in files) { if (entity is File && p.basenameWithoutExtension(entity.path) == bookId) { return entity.readAsBytes(); } } + return null; } /// Checks whether a stored book exists without reading its contents. Future hasBookFile(String bookId) async { final booksDir = await _getBooksDirectory(); + await for (final entity in booksDir.list()) { if (entity is File && p.basenameWithoutExtension(entity.path) == bookId) { return true; } } + return false; } @@ -110,9 +116,11 @@ class BookImportService { /// selected server. Future storeBookFile(String bookId, String extension, Uint8List bytes) async { final normalizedExtension = extension.toLowerCase().replaceFirst('.', ''); + if (normalizedExtension.isEmpty) { throw ArgumentError('Book file extension cannot be empty.'); } + await deleteBookFile(bookId); final booksDir = await _getBooksDirectory(); final file = File(p.join(booksDir.path, '$bookId.$normalizedExtension')); @@ -210,14 +218,20 @@ class BookImportService { }) async { await _withCoverLock(scope, CoverStorageBucket.pending, bookId, () async { final bytes = await _readCoverFile(scope, CoverStorageBucket.pending, bookId); - if (bytes == null) return; + + if (bytes == null) { + return; + } + await _withCoverLock( scope, CoverStorageBucket.cached, mediaId, () => _writeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes), ); + final pendingFile = await _coverFile(scope, CoverStorageBucket.pending, bookId); + if (await pendingFile.exists()) { await pendingFile.delete(); } @@ -227,6 +241,7 @@ class BookImportService { /// Deletes all cached private covers for one server/account scope. Future clearCoverFiles(MediaStorageScope scope) async { final directory = await _coverDirectory(scope, create: false); + if (directory != null && await directory.exists()) { await directory.delete(recursive: true); } @@ -239,9 +254,11 @@ class BookImportService { Future _getBooksDirectory() async { final appDir = await getApplicationDocumentsDirectory(); final booksDir = Directory(p.join(appDir.path, 'books')); + if (!booksDir.existsSync()) { await booksDir.create(recursive: true); } + return booksDir; } @@ -255,6 +272,7 @@ class BookImportService { final previous = _coverOperations[key] ?? Future.value(); final completer = Completer(); late final Future current; + current = previous.then((_) async { try { completer.complete(await operation()); @@ -262,7 +280,9 @@ class BookImportService { completer.completeError(error, stackTrace); } }); + _coverOperations[key] = current; + unawaited( current.whenComplete(() { if (identical(_coverOperations[key], current)) { @@ -270,12 +290,17 @@ class BookImportService { } }), ); + return completer.future; } Future _readCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { final file = await _coverFile(scope, bucket, id); - if (!await file.exists()) return null; + + if (!await file.exists()) { + return null; + } + return file.readAsBytes(); } @@ -283,6 +308,7 @@ class BookImportService { final file = await _coverFile(scope, bucket, id); final operationId = const Uuid().v4(); final tempFile = File('${file.path}.$operationId.tmp'); + try { await tempFile.writeAsBytes(bytes, flush: true); await tempFile.rename(file.path); @@ -295,9 +321,11 @@ class BookImportService { Future _removeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { final file = await _coverFile(scope, bucket, id); + if (await file.exists()) { await file.delete(); } + LocalCoverImageProvider.evictKey(scopeKey: scope.persistenceKey, bucket: bucket, fileId: id); } @@ -321,11 +349,17 @@ class BookImportService { required bool create, }) async { final scopeDirectory = await _coverDirectory(scope, create: create); - if (scopeDirectory == null) return null; + + if (scopeDirectory == null) { + return null; + } + final directory = Directory(p.join(scopeDirectory.path, bucket.pathComponent)); + if (create && !await directory.exists()) { await directory.create(recursive: true); } + return directory; } @@ -333,9 +367,11 @@ class BookImportService { _validateFilePart(scope.persistenceKey, 'scope'); final appDir = await getApplicationSupportDirectory(); final directory = Directory(p.join(appDir.path, 'media-covers', scope.persistenceKey)); + if (create && !await directory.exists()) { await directory.create(recursive: true); } + return directory; } } diff --git a/app/lib/services/book_import_session.dart b/app/lib/services/book_import_session.dart index 4b0c8269..9bfbdf39 100644 --- a/app/lib/services/book_import_session.dart +++ b/app/lib/services/book_import_session.dart @@ -20,6 +20,7 @@ class BookImportSession { required this.commit, required this.isCurrent, }); + final Future Function(Uint8List bytes, String filename) process; final Future Function(String bookId) deleteFile; final Future Function(BookImportResult result, String filename) commit; @@ -44,18 +45,22 @@ class BookImportSession { final authenticated = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; final scope = authenticated ? queue.activeScope : null; final userId = authProvider.user?.userId; + if (authenticated && scope == null) { throw StateError('Cannot import account media without an active media storage scope'); } + if ((authProvider.isSignedIn && (!authenticated || scope?.userId != userId)) || (authProvider.isOfflineMode && powerSyncService.mode != LibraryDatabaseMode.guest)) { throw StateError('Wait for the selected library to finish loading before importing.'); } + bool isCurrent() => dataStore.isBookRepositoryCurrent(repository) && authProvider.user?.userId == userId && (authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated) == authenticated && queue.activeScope == scope; + final service = BookImportCommitService( storePendingCover: importService.storePendingCoverFile, storeGuestCover: importService.storeGuestCoverFile, @@ -66,6 +71,7 @@ class BookImportSession { enqueueImportedBookMedia: queue.enqueueImportedBookMedia, isLibraryContextCurrent: isCurrent, ); + return BookImportSession( process: importService.importBook, deleteFile: importService.deleteBookFile, diff --git a/app/lib/services/file_metadata_service.dart b/app/lib/services/file_metadata_service.dart index 2f5cb814..8ec64b45 100644 --- a/app/lib/services/file_metadata_service.dart +++ b/app/lib/services/file_metadata_service.dart @@ -102,25 +102,19 @@ class FileMetadataService { default: return FileMetadataResult(warnings: ['Unsupported file format: $ext']); } - } catch (e) { - return FileMetadataResult(warnings: ['Failed to extract metadata: $e']); + } catch (error) { + return FileMetadataResult(warnings: ['Failed to extract metadata: $error']); } } - // ============================================================================ - // EPUB - // ============================================================================ - Future _extractEpub(Uint8List bytes) async { final warnings = []; - final book = await EpubReader.readBook(bytes); final metadata = book.schema?.package?.metadata; - final title = _tryRead('EPUB title', warnings, () => book.title); final authors = _tryRead('EPUB authors', warnings, () { - final raw = book.authors.whereType().where((a) => a.isNotEmpty).toList(); + final raw = book.authors.whereType().where((value) => value.isNotEmpty).toList(); return raw.isNotEmpty ? raw : null; }); @@ -128,12 +122,11 @@ class FileMetadataService { final description = _tryRead('EPUB description', warnings, () => metadata?.description); final language = _tryRead('EPUB language', warnings, () => metadata?.languages.firstOrNull); final publishedDate = _tryRead('EPUB date', warnings, () => _findEpubDate(metadata?.dates)); - final isbns = _tryRead('EPUB identifiers', warnings, () => _findEpubIsbns(metadata?.identifiers)); - Uint8List? coverImageBytes; String? coverImageMimeType; final coverImage = _tryRead('EPUB cover image', warnings, () => book.coverImage); + if (coverImage != null) { coverImageBytes = img.encodePng(coverImage); coverImageMimeType = 'image/png'; @@ -155,22 +148,29 @@ class FileMetadataService { } String? _findEpubDate(List? dates) { - if (dates == null || dates.isEmpty) return null; + if (dates == null || dates.isEmpty) { + return null; + } // Prefer publication date, fall back to first date. - final pubDate = dates.where((d) => d.event?.toLowerCase() == 'publication').firstOrNull; + final pubDate = dates.where((item) => item.event?.toLowerCase() == 'publication').firstOrNull; return (pubDate ?? dates.first).date; } (String?, String?) _findEpubIsbns(List? ids) { - if (ids == null || ids.isEmpty) return (null, null); + if (ids == null || ids.isEmpty) { + return (null, null); + } String? isbn; String? isbn13; for (final id in ids) { final value = id.identifier; - if (value == null) continue; + + if (value == null) { + continue; + } final clean = value.replaceAll(RegExp(r'[-\s]'), ''); final isIsbnScheme = id.scheme?.toLowerCase() == 'isbn'; @@ -188,10 +188,6 @@ class FileMetadataService { return (isbn, isbn13); } - // ============================================================================ - // PDF - // ============================================================================ - FileMetadataResult _extractPdf(Uint8List bytes) { final warnings = []; final document = PdfDocument(inputBytes: bytes); @@ -201,41 +197,55 @@ class FileMetadataService { // Title String? title; + try { final t = info.title; - if (t.isNotEmpty) title = t; - } catch (e) { - warnings.add('Could not read PDF title: $e'); + + if (t.isNotEmpty) { + title = t; + } + } catch (error) { + warnings.add('Could not read PDF title: $error'); } // Author List? authors; + try { final a = info.author; - if (a.isNotEmpty) authors = [a]; - } catch (e) { - warnings.add('Could not read PDF author: $e'); + + if (a.isNotEmpty) { + authors = [a]; + } + } catch (error) { + warnings.add('Could not read PDF author: $error'); } // Subject → description String? description; + try { final s = info.subject; - if (s.isNotEmpty) description = s; - } catch (e) { - warnings.add('Could not read PDF subject: $e'); + + if (s.isNotEmpty) { + description = s; + } + } catch (error) { + warnings.add('Could not read PDF subject: $error'); } // Page count int? pageCount; + try { pageCount = document.pages.count; - } catch (e) { - warnings.add('Could not read PDF page count: $e'); + } catch (error) { + warnings.add('Could not read PDF page count: $error'); } // Published date from creation date String? publishedDate; + try { final date = info.creationDate; publishedDate = date.toIso8601String().split('T').first; @@ -256,10 +266,6 @@ class FileMetadataService { } } - // ============================================================================ - // MOBI / AZW3 - // ============================================================================ - Future _extractMobi(Uint8List bytes) async { final warnings = []; final mobiData = await DartMobiReader.read(bytes); @@ -267,17 +273,20 @@ class FileMetadataService { String? getExthString(MobiExthTag tag) { try { final record = DartMobiReader.getExthRecordByTag(mobiData, tag); + if (record?.data != null) { return utf8.decode(record!.data!, allowMalformed: true).trim(); } - } catch (e) { - warnings.add('Could not read MOBI ${tag.name}: $e'); + } catch (error) { + warnings.add('Could not read MOBI ${tag.name}: $error'); } + return null; } // Title — prefer EXTH title, fall back to MOBI header fullname. String? title = getExthString(MobiExthTag.title); + if (title == null || title.isEmpty) { try { title = mobiData.mobiHeader?.fullname; @@ -287,9 +296,14 @@ class FileMetadataService { // Author final authorStr = getExthString(MobiExthTag.author); List? authors; + if (authorStr != null && authorStr.isNotEmpty) { // Authors can be separated by '&', ';', or ',' - authors = authorStr.split(RegExp(r'[;&,]')).map((a) => a.trim()).where((a) => a.isNotEmpty).toList(); + authors = authorStr + .split(RegExp(r'[;&,]')) + .map((value) => value.trim()) + .where((value) => value.isNotEmpty) + .toList(); } final publisher = getExthString(MobiExthTag.publisher); @@ -301,27 +315,30 @@ class FileMetadataService { // Cover extraction Uint8List? coverImageBytes; String? coverImageMimeType; + try { final coverRecord = DartMobiReader.getExthRecordByTag(mobiData, MobiExthTag.coverOffset); + if (coverRecord?.data != null) { final offset = DartMobiReader.decodeExthValue(coverRecord!.data!, coverRecord.size!); final imageIndex = mobiData.mobiHeader?.imageIndex ?? 0; final coverRecordIndex = imageIndex + offset; - var record = mobiData.mobiPdbRecord; var currentIndex = 0; + while (record != null) { if (currentIndex == coverRecordIndex) { coverImageBytes = record.data; coverImageMimeType = _guessImageMimeType(record.data); break; } + currentIndex++; record = record.next; } } - } catch (e) { - warnings.add('Could not extract MOBI cover image: $e'); + } catch (error) { + warnings.add('Could not extract MOBI cover image: $error'); } if (coverImageBytes == null) { @@ -331,8 +348,10 @@ class FileMetadataService { // Classify ISBNs String? isbn10; String? isbn13; + if (isbn != null) { final clean = isbn.replaceAll(RegExp(r'[-\s]'), ''); + if (clean.length == 13) { isbn13 = clean; } else if (clean.length == 10) { @@ -355,31 +374,31 @@ class FileMetadataService { ); } - // ============================================================================ - // CBZ (ZIP-based comic archive) - // ============================================================================ - FileMetadataResult _extractCbz(Uint8List bytes) { final warnings = []; final archive = ZipDecoder().decodeBytes(bytes); - final comicInfoBytes = archive.files.where((f) => f.name.toLowerCase() == 'comicinfo.xml').firstOrNull?.content; + final comicInfoBytes = archive.files + .where((item) => item.name.toLowerCase() == 'comicinfo.xml') + .firstOrNull + ?.content; final comicInfo = _parseComicInfo(comicInfoBytes, 'CBZ', warnings); // Cover: first image file (sorted alphabetically) Uint8List? coverImageBytes; String? coverImageMimeType; + try { - final imageFiles = archive.files.where((f) => f.isFile && _isImageFile(f.name)).toList() - ..sort((a, b) => a.name.compareTo(b.name)); + final imageFiles = archive.files.where((item) => item.isFile && _isImageFile(item.name)).toList() + ..sort((left, right) => left.name.compareTo(right.name)); if (imageFiles.isNotEmpty) { coverImageBytes = imageFiles.first.content; coverImageMimeType = _guessImageMimeType(coverImageBytes); } - } catch (e) { - warnings.add('Could not extract CBZ cover image: $e'); + } catch (error) { + warnings.add('Could not extract CBZ cover image: $error'); } return FileMetadataResult( @@ -395,10 +414,6 @@ class FileMetadataService { ); } - // ============================================================================ - // CBR (RAR-based comic archive) - // ============================================================================ - Future _extractCbr(Uint8List bytes, String filename) async { final warnings = []; @@ -413,7 +428,7 @@ class FileMetadataService { final List files = rar.files; final comicInfoBytes = files - .where((f) => f.name?.toLowerCase() == 'comicinfo.xml' && f.content != null) + .where((item) => item.name?.toLowerCase() == 'comicinfo.xml' && item.content != null) .firstOrNull ?.content; @@ -422,16 +437,18 @@ class FileMetadataService { // Cover: first image file (sorted alphabetically) Uint8List? coverImageBytes; String? coverImageMimeType; + try { - final imageFiles = files.where((f) => f.content != null && f.name != null && _isImageFile(f.name!)).toList() - ..sort((a, b) => a.name!.compareTo(b.name!)); + final imageFiles = + files.where((item) => item.content != null && item.name != null && _isImageFile(item.name!)).toList() + ..sort((left, right) => left.name!.compareTo(right.name!)); if (imageFiles.isNotEmpty) { coverImageBytes = imageFiles.first.content; coverImageMimeType = _guessImageMimeType(coverImageBytes); } - } catch (e) { - warnings.add('Could not extract CBR cover image: $e'); + } catch (error) { + warnings.add('Could not extract CBR cover image: $error'); } return FileMetadataResult( @@ -445,8 +462,8 @@ class FileMetadataService { coverImageMimeType: coverImageMimeType, warnings: warnings, ); - } catch (e) { - warnings.add('Could not read CBR archive: $e'); + } catch (error) { + warnings.add('Could not read CBR archive: $error'); return FileMetadataResult(warnings: warnings); } finally { try { @@ -455,19 +472,15 @@ class FileMetadataService { } } - // ============================================================================ - // TXT - // ============================================================================ - FileMetadataResult _extractTxt(Uint8List bytes, String filename) { final warnings = []; final basename = p.basenameWithoutExtension(filename); - String? title; List? authors; // Try "Author - Title" pattern final parts = basename.split(' - '); + if (parts.length >= 2) { authors = [parts.first.trim()]; title = parts.sublist(1).join(' - ').trim(); @@ -478,26 +491,26 @@ class FileMetadataService { // Estimate page count from byte length int? pageCount; + try { pageCount = (bytes.length / _charsPerPage).ceil(); - if (pageCount == 0) pageCount = 1; - } catch (e) { - warnings.add('Could not estimate page count: $e'); + + if (pageCount == 0) { + pageCount = 1; + } + } catch (error) { + warnings.add('Could not estimate page count: $error'); } return FileMetadataResult(title: title, authors: authors, pageCount: pageCount, warnings: warnings); } - // ============================================================================ - // Helpers - // ============================================================================ - /// Try reading a metadata field; on failure, add a warning and return null. T? _tryRead(String field, List warnings, T? Function() read) { try { return read(); - } catch (e) { - warnings.add('Could not read $field: $e'); + } catch (error) { + warnings.add('Could not read $field: $error'); return null; } } @@ -514,20 +527,22 @@ class FileMetadataService { try { final doc = XmlDocument.parse(utf8.decode(xmlBytes)); final info = doc.rootElement; - var title = _xmlText(info, 'Title'); final series = _xmlText(info, 'Series'); final number = _xmlText(info, 'Number'); + if (title == null && series != null) { title = number != null ? '$series #$number' : series; } // Authors: Writer, Penciller, etc. final authorSet = {}; + for (final role in ['Writer', 'Penciller']) { final value = _xmlText(info, role); + if (value != null) { - authorSet.addAll(value.split(',').map((a) => a.trim())); + authorSet.addAll(value.split(',').map((value) => value.trim())); } } @@ -541,8 +556,8 @@ class FileMetadataService { language: _xmlText(info, 'LanguageISO'), pageCount: pageCountStr != null ? int.tryParse(pageCountStr) : null, ); - } catch (e) { - warnings.add('Could not parse ComicInfo.xml: $e'); + } catch (error) { + warnings.add('Could not parse ComicInfo.xml: $error'); return const _ComicInfoData(); } } @@ -550,7 +565,11 @@ class FileMetadataService { /// Get text content of a direct child XML element by tag name. String? _xmlText(XmlElement parent, String tagName) { final el = parent.findElements(tagName).firstOrNull; - if (el == null) return null; + + if (el == null) { + return null; + } + final text = el.innerText.trim(); return text.isEmpty ? null : text; } @@ -562,25 +581,31 @@ class FileMetadataService { /// Guess MIME type from image bytes by checking magic bytes. String? _guessImageMimeType(Uint8List? bytes) { - if (bytes == null || bytes.length < 4) return null; + if (bytes == null || bytes.length < 4) { + return null; + } // JPEG: FF D8 FF if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) { return 'image/jpeg'; } // PNG: 89 50 4E 47 + if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) { return 'image/png'; } // GIF: 47 49 46 + if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { return 'image/gif'; } // BMP: 42 4D + if (bytes[0] == 0x42 && bytes[1] == 0x4D) { return 'image/bmp'; } // WebP: 52 49 46 46 ... 57 45 42 50 + if (bytes.length >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && diff --git a/app/lib/services/metadata_service.dart b/app/lib/services/metadata_service.dart index 57a81f46..0dbe0bb6 100644 --- a/app/lib/services/metadata_service.dart +++ b/app/lib/services/metadata_service.dart @@ -57,9 +57,31 @@ class MetadataService { MetadataService({http.Client? client}) : _client = client ?? http.Client(); + static Uri _openLibrarySearchUri(String query, {int limit = 10}) { + return Uri.parse('https://openlibrary.org/search.json?q=${Uri.encodeComponent(query)}&limit=$limit'); + } + + static Uri _openLibraryIsbnUri(String isbn, {int limit = 5}) { + return Uri.parse('https://openlibrary.org/search.json?isbn=$isbn&limit=$limit'); + } + + static Uri _googleBooksSearchUri(String query, {int limit = 10}) { + return Uri.parse('https://www.googleapis.com/books/v1/volumes?q=${Uri.encodeComponent(query)}&maxResults=$limit'); + } + + static Uri _googleBooksIsbnUri(String isbn, {int limit = 5}) { + return Uri.parse('https://www.googleapis.com/books/v1/volumes?q=isbn:$isbn&maxResults=$limit'); + } + + static String _openLibraryCoverUrl(Object coverId) { + return 'https://covers.openlibrary.org/b/id/$coverId-L.jpg'; + } + /// Search for books by query (title, author, or general search). Future> search(String query, MetadataSource source) async { - if (query.trim().isEmpty) return []; + if (query.trim().isEmpty) { + return []; + } switch (source) { case MetadataSource.openLibrary: @@ -72,7 +94,10 @@ class MetadataService { /// Search for a book by ISBN. Future> searchByIsbn(String isbn, MetadataSource source) async { final cleanIsbn = isbn.replaceAll(RegExp(r'[-\s]'), ''); - if (cleanIsbn.isEmpty) return []; + + if (cleanIsbn.isEmpty) { + return []; + } switch (source) { case MetadataSource.openLibrary: @@ -82,139 +107,133 @@ class MetadataService { } } - // ============================================================================ - // OPEN LIBRARY API - // ============================================================================ - Future> _searchOpenLibrary(String query) async { try { - final uri = Uri.parse('https://openlibrary.org/search.json?q=${Uri.encodeComponent(query)}&limit=10'); + final uri = _openLibrarySearchUri(query); final response = await _client.get(uri); - if (response.statusCode != 200) return []; + if (response.statusCode != 200) { + return []; + } final data = jsonDecode(response.body) as Map; final docs = data['docs'] as List? ?? []; - - return docs.map((doc) => _parseOpenLibraryDoc(doc)).toList(); - } catch (e) { + return docs.map((document) => _parseOpenLibraryDoc(document)).toList(); + } catch (error) { return []; } } Future> _searchOpenLibraryByIsbn(String isbn) async { try { - final uri = Uri.parse('https://openlibrary.org/search.json?isbn=$isbn&limit=5'); + final uri = _openLibraryIsbnUri(isbn); final response = await _client.get(uri); - if (response.statusCode != 200) return []; + if (response.statusCode != 200) { + return []; + } final data = jsonDecode(response.body) as Map; final docs = data['docs'] as List? ?? []; - - return docs.map((doc) => _parseOpenLibraryDoc(doc)).toList(); - } catch (e) { + return docs.map((document) => _parseOpenLibraryDoc(document)).toList(); + } catch (error) { return []; } } - BookMetadataResult _parseOpenLibraryDoc(Map doc) { - // Get cover URL from cover_i (cover ID) + BookMetadataResult _parseOpenLibraryDoc(Map document) { String? coverUrl; - final coverId = doc['cover_i']; + final coverId = document['cover_i']; + if (coverId != null) { - coverUrl = 'https://covers.openlibrary.org/b/id/$coverId-L.jpg'; + coverUrl = _openLibraryCoverUrl(coverId); } - // Get ISBNs - final isbns = doc['isbn'] as List?; + final isbns = document['isbn'] as List?; String? isbn; String? isbn13; + if (isbns != null && isbns.isNotEmpty) { - for (final i in isbns) { - final isbnStr = i.toString(); - if (isbnStr.length == 10 && isbn == null) { - isbn = isbnStr; - } else if (isbnStr.length == 13 && isbn13 == null) { - isbn13 = isbnStr; + for (final identifier in isbns) { + final isbnValue = identifier.toString(); + + if (isbnValue.length == 10 && isbn == null) { + isbn = isbnValue; + } else if (isbnValue.length == 13 && isbn13 == null) { + isbn13 = isbnValue; } } } return BookMetadataResult( source: MetadataSource.openLibrary, - title: doc['title'] as String?, - subtitle: doc['subtitle'] as String?, - authors: (doc['author_name'] as List?)?.cast(), - publisher: (doc['publisher'] as List?)?.firstOrNull as String?, - publishedDate: doc['first_publish_year']?.toString(), + title: document['title'] as String?, + subtitle: document['subtitle'] as String?, + authors: (document['author_name'] as List?)?.cast(), + publisher: (document['publisher'] as List?)?.firstOrNull as String?, + publishedDate: document['first_publish_year']?.toString(), description: null, // Open Library search doesn't include description coverUrl: coverUrl, - language: (doc['language'] as List?)?.firstOrNull as String?, + language: (document['language'] as List?)?.firstOrNull as String?, isbn: isbn, isbn13: isbn13, - pageCount: doc['number_of_pages_median'] as int?, + pageCount: document['number_of_pages_median'] as int?, ); } - // ============================================================================ - // GOOGLE BOOKS API - // ============================================================================ - Future> _searchGoogleBooks(String query) async { try { - final uri = Uri.parse( - 'https://www.googleapis.com/books/v1/volumes?q=${Uri.encodeComponent(query)}&maxResults=10', - ); + final uri = _googleBooksSearchUri(query); final response = await _client.get(uri); - if (response.statusCode != 200) return []; + if (response.statusCode != 200) { + return []; + } final data = jsonDecode(response.body) as Map; final items = data['items'] as List? ?? []; - return items.map((item) => _parseGoogleBooksItem(item)).toList(); - } catch (e) { + } catch (error) { return []; } } Future> _searchGoogleBooksByIsbn(String isbn) async { try { - final uri = Uri.parse('https://www.googleapis.com/books/v1/volumes?q=isbn:$isbn&maxResults=5'); + final uri = _googleBooksIsbnUri(isbn); final response = await _client.get(uri); - if (response.statusCode != 200) return []; + if (response.statusCode != 200) { + return []; + } final data = jsonDecode(response.body) as Map; final items = data['items'] as List? ?? []; - return items.map((item) => _parseGoogleBooksItem(item)).toList(); - } catch (e) { + } catch (error) { return []; } } BookMetadataResult _parseGoogleBooksItem(Map item) { final volumeInfo = item['volumeInfo'] as Map? ?? {}; - - // Get cover URL (prefer larger images) String? coverUrl; final imageLinks = volumeInfo['imageLinks'] as Map?; + if (imageLinks != null) { coverUrl = imageLinks['large'] as String? ?? imageLinks['medium'] as String? ?? imageLinks['thumbnail'] as String?; - // Convert HTTP to HTTPS coverUrl = coverUrl?.replaceFirst('http://', 'https://'); } - // Get ISBNs from industry identifiers String? isbn; String? isbn13; final identifiers = volumeInfo['industryIdentifiers'] as List? ?? []; + for (final id in identifiers) { final type = id['type'] as String?; final identifier = id['identifier'] as String?; + if (type == 'ISBN_10') { isbn = identifier; } else if (type == 'ISBN_13') { diff --git a/app/lib/services/reading_device_identity.dart b/app/lib/services/reading_device_identity.dart index 85e50fa3..9861dc10 100644 --- a/app/lib/services/reading_device_identity.dart +++ b/app/lib/services/reading_device_identity.dart @@ -10,6 +10,7 @@ abstract final class ReadingDeviceIdentity { static Future initialize(SharedPreferences preferences) async { final stored = preferences.getString(preferenceKey); + if (stored != null && stored.isNotEmpty) { _value = stored; } else { diff --git a/app/lib/themes/app_motion.dart b/app/lib/themes/app_motion.dart index 48814a17..b81c2979 100644 --- a/app/lib/themes/app_motion.dart +++ b/app/lib/themes/app_motion.dart @@ -34,6 +34,7 @@ class AppMotionScope extends StatelessWidget { Widget build(BuildContext context) { final media = MediaQuery.of(context); final disabled = reduceAnimations || AppMotion.disabled(context); + return MediaQuery( data: media.copyWith(disableAnimations: disabled), child: HeroMode(enabled: !disabled, child: child), diff --git a/app/lib/themes/app_theme.dart b/app/lib/themes/app_theme.dart index 1915f72b..8c601611 100644 --- a/app/lib/themes/app_theme.dart +++ b/app/lib/themes/app_theme.dart @@ -9,10 +9,6 @@ import 'design_tokens.dart'; class AppTheme { AppTheme._(); - // =========================================================================== - // LIGHT THEME - // =========================================================================== - static ThemeData get light => ThemeData( useMaterial3: true, colorScheme: lightColorScheme, @@ -31,10 +27,6 @@ class AppTheme { popupMenuTheme: _popupMenuTheme(lightColorScheme), ); - // =========================================================================== - // DARK THEME - // =========================================================================== - static ThemeData get dark => ThemeData( useMaterial3: true, colorScheme: darkColorScheme, @@ -53,10 +45,6 @@ class AppTheme { popupMenuTheme: _popupMenuTheme(darkColorScheme), ); - // =========================================================================== - // E-INK THEME - // =========================================================================== - static ThemeData get eink => ThemeData( useMaterial3: true, colorScheme: einkColorScheme, @@ -152,10 +140,6 @@ class AppTheme { splashColor: Colors.transparent, ); - // =========================================================================== - // TYPOGRAPHY - // =========================================================================== - static const TextTheme _textTheme = TextTheme( displayLarge: TextStyle(fontSize: 57, fontWeight: FontWeight.w400, letterSpacing: -0.25), displayMedium: TextStyle(fontSize: 45, fontWeight: FontWeight.w400, letterSpacing: 0), @@ -183,10 +167,6 @@ class AppTheme { ) .apply(bodyColor: EinkColors.black, displayColor: EinkColors.black); - // =========================================================================== - // STANDARD COMPONENT THEMES - // =========================================================================== - static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme colors) { return ElevatedButtonThemeData( style: ElevatedButton.styleFrom( @@ -310,10 +290,6 @@ class AppTheme { ); } - // =========================================================================== - // E-INK COMPONENT THEMES - // =========================================================================== - static ElevatedButtonThemeData _einkElevatedButtonTheme() { return ElevatedButtonThemeData( style: ElevatedButton.styleFrom( diff --git a/app/lib/themes/color_schemes.g.dart b/app/lib/themes/color_schemes.g.dart index 3bbe382a..d4a1b5e3 100644 --- a/app/lib/themes/color_schemes.g.dart +++ b/app/lib/themes/color_schemes.g.dart @@ -1,9 +1,5 @@ import 'package:flutter/material.dart'; -// ============================================================================= -// LIGHT COLOR SCHEME -// ============================================================================= - const lightColorScheme = ColorScheme( brightness: Brightness.light, primary: Color(0xFF5654A8), @@ -36,10 +32,6 @@ const lightColorScheme = ColorScheme( scrim: Color(0xFF000000), ); -// ============================================================================= -// DARK COLOR SCHEME -// ============================================================================= - const darkColorScheme = ColorScheme( brightness: Brightness.dark, primary: Color(0xFFC3C0FF), @@ -72,9 +64,6 @@ const darkColorScheme = ColorScheme( scrim: Color(0xFF000000), ); -// ============================================================================= -// E-INK COLOR SCHEME -// ============================================================================= // High contrast, grayscale-only colors optimized for e-ink displays. // Rules: // - NO gradients - use solid fills only diff --git a/app/lib/themes/design_tokens.dart b/app/lib/themes/design_tokens.dart index 4f9666e9..0c8ba6b1 100644 --- a/app/lib/themes/design_tokens.dart +++ b/app/lib/themes/design_tokens.dart @@ -1,10 +1,6 @@ // Design tokens for the Papyrus book management application. // These tokens define the visual language across all platforms and themes. -// ============================================================================= -// SPACING SYSTEM (8px grid) -// ============================================================================= - class Spacing { Spacing._(); @@ -30,10 +26,6 @@ class Spacing { static const double pageMarginsEink = 48.0; } -// ============================================================================= -// RESPONSIVE BREAKPOINTS -// ============================================================================= - class Breakpoints { Breakpoints._(); @@ -60,10 +52,6 @@ class Breakpoints { static const double desktopLargeMargin = 32.0; } -// ============================================================================= -// BORDER RADIUS -// ============================================================================= - class AppRadius { AppRadius._(); @@ -89,10 +77,6 @@ class AppRadius { static const double einkCard = 0.0; } -// ============================================================================= -// TOUCH TARGETS -// ============================================================================= - class TouchTargets { TouchTargets._(); @@ -112,10 +96,6 @@ class TouchTargets { static const double einkSpacing = 16.0; } -// ============================================================================= -// COMPONENT SIZES -// ============================================================================= - class ComponentSizes { ComponentSizes._(); @@ -164,10 +144,6 @@ class ComponentSizes { static const double bookCoverHeightList = 90.0; } -// ============================================================================= -// ELEVATION / SHADOWS -// ============================================================================= - class AppElevation { AppElevation._(); @@ -182,10 +158,6 @@ class AppElevation { static const double eink = 0.0; } -// ============================================================================= -// ANIMATION DURATIONS -// ============================================================================= - class AnimationDurations { AnimationDurations._(); @@ -198,10 +170,6 @@ class AnimationDurations { static const Duration eink = Duration.zero; } -// ============================================================================= -// BORDER WIDTHS -// ============================================================================= - class BorderWidths { BorderWidths._(); @@ -220,10 +188,6 @@ class BorderWidths { static const double einkError = 4.0; } -// ============================================================================= -// ICON SIZES -// ============================================================================= - class IconSizes { IconSizes._(); diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index 8b8a82ac..e34f4195 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -50,7 +50,11 @@ void showBookContextMenu({required BuildContext context, required Book book, Off onStatusChange: (status) { final dataStore = context.read(); final currentBook = dataStore.getBook(book.id); - if (currentBook == null) return; + + if (currentBook == null) { + return; + } + dataStore.updateBook(currentBook.copyWith(readingStatus: status)); }, onDownload: () { @@ -60,6 +64,7 @@ void showBookContextMenu({required BuildContext context, required Book book, Off final mediaUploadQueue = context.read(); final importService = context.read(); final mediaScope = mediaUploadQueue.activeScope; + unawaited( deleteBookWithMediaCleanup( dataStore: context.read(), @@ -100,7 +105,10 @@ Future _downloadBookFile(BuildContext context, Book book) async { try { final cached = await mediaCacheService.getValidCachedBookFile(book, readLocalBookFile: importService.getBookFile); - if (!context.mounted) return; + + if (!context.mounted) { + return; + } final bytes = cached ?? @@ -110,10 +118,15 @@ Future _downloadBookFile(BuildContext context, Book book) async { writeLocalBookFile: importService.storeBookFile, downloadMedia: context.read().downloadMedia, ); + final result = await downloadService.saveBookFile(book: book, bytes: bytes); - if (!context.mounted) return; + if (!context.mounted) { + return; + } + messenger.hideCurrentSnackBar(); + if (result.saved) { messenger.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), @@ -126,8 +139,12 @@ Future _downloadBookFile(BuildContext context, Book book) async { ); } } catch (_) { - if (!context.mounted) return; + if (!context.mounted) { + return; + } + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Could not download this book file.')), diff --git a/app/lib/utils/book_language.dart b/app/lib/utils/book_language.dart index 81c9fad2..070d6cc4 100644 --- a/app/lib/utils/book_language.dart +++ b/app/lib/utils/book_language.dart @@ -105,6 +105,7 @@ const Map _languageAliases = { String? normalizeBookLanguage(String? language) { final normalized = language?.trim().toLowerCase(); + if (normalized == null || normalized.isEmpty) { return null; } @@ -114,6 +115,7 @@ String? normalizeBookLanguage(String? language) { String bookLanguageLabel(String language) { final normalized = normalizeBookLanguage(language); + if (normalized == null) { return ''; } diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index 2a6301f7..3a2b6737 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -15,10 +15,6 @@ import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; import 'package:provider/provider.dart'; import 'package:papyrus/themes/app_motion.dart'; -// ============================================================================= -// LOW-LEVEL BULK OPERATIONS -// ============================================================================= - /// Add all selected books to the given shelves. Future bulkAddToShelves( DataStore dataStore, @@ -39,6 +35,7 @@ Future bulkAddTopics( void bulkChangeStatus(DataStore dataStore, Set bookIds, LibraryReadingStatus status) { for (final bookId in bookIds) { final book = dataStore.getBook(bookId); + if (book != null) { dataStore.updateBook(book.copyWith(readingStatus: status)); } @@ -54,18 +51,29 @@ Future bulkToggleFavorite(LibraryProvider libraryProvider, DataStore dataS }); final writes = >[]; + for (final bookId in bookIds) { final book = dataStore.getBook(bookId); - if (book == null) continue; + + if (book == null) { + continue; + } + final currentFav = libraryProvider.isBookFavorite(bookId, book.isFavorite); + if (allFavorite) { // Un-favorite all - if (currentFav) writes.add(libraryProvider.toggleFavorite(bookId, true)); + if (currentFav) { + writes.add(libraryProvider.toggleFavorite(bookId, true)); + } } else { // Favorite all - if (!currentFav) writes.add(libraryProvider.toggleFavorite(bookId, false)); + if (!currentFav) { + writes.add(libraryProvider.toggleFavorite(bookId, false)); + } } } + await Future.wait(writes); } @@ -76,10 +84,6 @@ void bulkDelete(DataStore dataStore, Set bookIds) { } } -// ============================================================================= -// UI HANDLERS (shared between LibraryPage and ShelfContentsPage) -// ============================================================================= - /// Show the move-to-shelf sheet for selected books. void handleBulkAddToShelf(BuildContext context, LibraryProvider libraryProvider) { final dataStore = context.read(); @@ -129,6 +133,7 @@ void handleBulkChangeStatus(BuildContext context, LibraryProvider libraryProvide /// Toggle favorite status for all selected books. Future handleBulkToggleFavorite(BuildContext context, LibraryProvider libraryProvider) async { final dataStore = context.read(); + try { await bulkToggleFavorite(libraryProvider, dataStore, libraryProvider.selectedBookIds); libraryProvider.exitSelectionMode(); @@ -165,8 +170,10 @@ void handleBulkDelete(BuildContext context, LibraryProvider libraryProvider) { final importService = context.read(); final mediaScope = mediaUploadQueue.activeScope; Navigator.pop(context); + for (final bookId in selectedBookIds) { final book = dataStore.getBook(bookId); + await deleteBookWithMediaCleanup( dataStore: dataStore, mediaUploadQueue: mediaUploadQueue, @@ -178,6 +185,7 @@ void handleBulkDelete(BuildContext context, LibraryProvider libraryProvider) { : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), ); } + libraryProvider.exitSelectionMode(); }, style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), diff --git a/app/lib/utils/image_utils.dart b/app/lib/utils/image_utils.dart index 743fe916..cffd95c7 100644 --- a/app/lib/utils/image_utils.dart +++ b/app/lib/utils/image_utils.dart @@ -19,6 +19,7 @@ String imageMimeType(Uint8List bytes) { return 'image/webp'; } } + return 'image/jpeg'; } diff --git a/app/lib/utils/navigation_utils.dart b/app/lib/utils/navigation_utils.dart index da425840..f45295ba 100644 --- a/app/lib/utils/navigation_utils.dart +++ b/app/lib/utils/navigation_utils.dart @@ -7,11 +7,18 @@ import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; /// - The current path starts with the item path (and item path is not '/') /// - Any child of the item has a path that the current path starts with bool isNavItemSelected(String currentPath, AppShellNavItem item) { - if (currentPath == item.path) return true; - if (currentPath.startsWith(item.path) && item.path != '/') return true; + if (currentPath == item.path) { + return true; + } + + if (currentPath.startsWith(item.path) && item.path != '/') { + return true; + } + if (item.children != null) { return item.children!.any((child) => currentPath.startsWith(child.path)); } + return false; } @@ -21,6 +28,6 @@ bool isNavItemSelected(String currentPath, AppShellNavItem item) { /// - '/library' returns false (single segment) /// - '/library/books' returns true (two segments) bool isChildPath(String path) { - final segments = path.split('/').where((s) => s.isNotEmpty).toList(); + final segments = path.split('/').where((value) => value.isNotEmpty).toList(); return segments.length > 1; } diff --git a/app/lib/utils/responsive.dart b/app/lib/utils/responsive.dart index faac8e64..eebf162a 100644 --- a/app/lib/utils/responsive.dart +++ b/app/lib/utils/responsive.dart @@ -10,18 +10,16 @@ enum DeviceType { mobile, tablet, desktop } class Responsive { Responsive._(); - // =========================================================================== - // BREAKPOINT DETECTION - // =========================================================================== - /// Get the current device type based on screen width static DeviceType getDeviceType(BuildContext context) { final width = MediaQuery.of(context).size.width; + if (width >= Breakpoints.desktopSmall) { return DeviceType.desktop; } else if (width >= Breakpoints.tablet) { return DeviceType.tablet; } + return DeviceType.mobile; } @@ -55,59 +53,72 @@ class Responsive { return MediaQuery.of(context).size.width >= Breakpoints.desktopLarge; } - // =========================================================================== - // PLATFORM DETECTION - // =========================================================================== - /// Check if running on web static bool get isWeb => kIsWeb; /// Check if running on mobile platform (iOS or Android) static bool get isMobilePlatform { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isIOS || Platform.isAndroid; } /// Check if running on desktop platform (Windows, macOS, Linux) static bool get isDesktopPlatform { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isWindows || Platform.isMacOS || Platform.isLinux; } /// Check if running on iOS static bool get isIOS { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isIOS; } /// Check if running on Android static bool get isAndroid { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isAndroid; } /// Check if running on Windows static bool get isWindows { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isWindows; } /// Check if running on macOS static bool get isMacOS { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isMacOS; } /// Check if running on Linux static bool get isLinux { - if (kIsWeb) return false; + if (kIsWeb) { + return false; + } + return Platform.isLinux; } - // =========================================================================== - // RESPONSIVE VALUES - // =========================================================================== - /// Get page margin based on device type static double getPageMargin(BuildContext context) { switch (getDeviceType(context)) { diff --git a/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart b/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart index 7b9ec92a..9792c358 100644 --- a/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart +++ b/app/lib/widgets/acquisition/acquisition_endpoint_editor.dart @@ -41,6 +41,7 @@ Future showAcquisitionEndpointEditor({ }) async { final busy = ValueNotifier(false); final editorKey = GlobalKey(); + final editor = AcquisitionEndpointEditor( key: editorKey, endpoint: endpoint, @@ -324,13 +325,17 @@ class _AcquisitionEndpointEditorState extends State { } Future _testConnection() async { - if (!(_urlFieldKey.currentState?.validate() ?? false)) return; + if (!(_urlFieldKey.currentState?.validate() ?? false)) { + return; + } final baseUrl = Uri.parse(_urlController.text.trim()); + setState(() { _testing = true; _message = null; }); + widget.onBusyChanged(_busy); try { @@ -341,14 +346,19 @@ class _AcquisitionEndpointEditorState extends State { username: _usesUsername ? _optional(_usernameController.text) : null, password: _usesPassword ? _optional(_passwordController.text) : null, ); - if (!mounted) return; + + if (!mounted) { + return; + } setState(() { _message = 'Connection successful.'; _messageIsError = false; }); } catch (error) { - if (!mounted) return; + if (!mounted) { + return; + } setState(() { _message = error is AuthApiException ? error.message : 'Could not connect to this integration.'; @@ -363,13 +373,17 @@ class _AcquisitionEndpointEditorState extends State { } Future _save() async { - if (!(_formKey.currentState?.validate() ?? false)) return; + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } var saved = false; + setState(() { _saving = true; _message = null; }); + widget.onBusyChanged(_busy); try { @@ -383,12 +397,17 @@ class _AcquisitionEndpointEditorState extends State { username: _usesUsername ? _optional(_usernameController.text) : null, password: _usesPassword ? _optional(_passwordController.text) : null, ); - if (!mounted) return; + + if (!mounted) { + return; + } Navigator.pop(context, true); saved = true; } catch (error) { - if (!mounted) return; + if (!mounted) { + return; + } setState(() { _message = error is AuthApiException ? error.message : 'Could not save this integration.'; @@ -404,9 +423,11 @@ class _AcquisitionEndpointEditorState extends State { String? _validateUrl(String? value) { final uri = Uri.tryParse(value?.trim() ?? ''); + if (uri == null || !uri.hasScheme || uri.host.isEmpty || uri.userInfo.isNotEmpty) { return 'Enter a valid server URL'; } + return null; } diff --git a/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart b/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart index 7e623b03..c6d431ed 100644 --- a/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart +++ b/app/lib/widgets/acquisition/guarded_bottom_sheet_route.dart @@ -11,7 +11,6 @@ Future showGuardedModalBottomSheet({ }) { assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMaterialLocalizations(context)); - final navigator = Navigator.of(context, rootNavigator: useRootNavigator); final localizations = MaterialLocalizations.of(context); final reduceAnimations = AppMotion.disabled(context); diff --git a/app/lib/widgets/add_book/add_book_choice_sheet.dart b/app/lib/widgets/add_book/add_book_choice_sheet.dart index 8c681f54..e64dcbec 100644 --- a/app/lib/widgets/add_book/add_book_choice_sheet.dart +++ b/app/lib/widgets/add_book/add_book_choice_sheet.dart @@ -24,6 +24,7 @@ class AddBookChoiceSheet extends StatefulWidget { ImportedBookFileDeleter? deleteImportedBookFile, }) async { Future<_AddBookChoice?>? sheetCompleted; + final choice = await showModalBottomSheet<_AddBookChoice>( sheetAnimationStyle: AppMotion.animationStyle(context), context: context, @@ -33,7 +34,6 @@ class AddBookChoiceSheet extends StatefulWidget { shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (sheetContext) { sheetCompleted = ModalRoute.of<_AddBookChoice>(sheetContext)?.completed; - return AddBookChoiceSheet(callerContext: context, onFindOnline: onFindOnline); }, ); diff --git a/app/lib/widgets/add_book/add_physical_book_sheet.dart b/app/lib/widgets/add_book/add_physical_book_sheet.dart index adb06aff..dfca5faa 100644 --- a/app/lib/widgets/add_book/add_physical_book_sheet.dart +++ b/app/lib/widgets/add_book/add_physical_book_sheet.dart @@ -123,6 +123,7 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { Future _onScanBarcode() async { final isbn = await IsbnScannerDialog.show(context); + if (isbn != null && isbn.isNotEmpty && mounted) { _isbnController.text = isbn; _lookupIsbn(isbn); @@ -130,7 +131,9 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { } Future _lookupIsbn(String isbn) async { - if (isbn.trim().isEmpty) return; + if (isbn.trim().isEmpty) { + return; + } setState(() { _lookupState = _IsbnLookupState.fetching; @@ -146,42 +149,58 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { results = await _metadataService.searchByIsbn(isbn.trim(), MetadataSource.googleBooks); } - if (!mounted) return; + if (!mounted) { + return; + } if (results.isNotEmpty) { final result = results.first; + setState(() { - if (result.title != null) _titleController.text = result.title!; + if (result.title != null) { + _titleController.text = result.title!; + } + if (result.primaryAuthor.isNotEmpty) { _authorController.text = result.primaryAuthor; } + if (result.subtitle != null) { _subtitleController.text = result.subtitle!; } + if (result.publisher != null) { _publisherController.text = result.publisher!; } + if (result.pageCount != null) { _pageCountController.text = result.pageCount.toString(); } + if (result.description != null) { _descriptionController.text = result.description!; } + if (result.isbn != null && _isbnController.text.isEmpty) { _isbnController.text = result.isbn!; } + _coverUrl = result.coverUrl; // New fields from metadata _coAuthors = result.coAuthors; + if (result.language != null) { _languageController.text = result.language!; } + if (result.isbn13 != null) { _isbn13Controller.text = result.isbn13!; } + if (result.publishedDate != null) { _publicationDate = _parsePublishedDate(result.publishedDate!); + if (_publicationDate != null) { _publicationDateController.text = DateFormat.yMMMMd().format(_publicationDate!); } @@ -196,7 +215,10 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { }); } } catch (e) { - if (!mounted) return; + if (!mounted) { + return; + } + setState(() { _lookupState = _IsbnLookupState.error; _lookupMessage = 'Could not look up. Check your internet connection.'; @@ -210,22 +232,29 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { return DateTime.parse(dateStr); } catch (_) {} // Try year only + final year = int.tryParse(dateStr); - if (year != null) return DateTime(year); + + if (year != null) { + return DateTime(year); + } + return null; } void _onSave() { - if (!_formKey.currentState!.validate()) return; + if (!_formKey.currentState!.validate()) { + return; + } final dataStore = context.read(); final now = DateTime.now(); - final pageCount = int.tryParse(_pageCountController.text.trim()); final seriesNumber = double.tryParse(_seriesNumberController.text.trim()); // Convert cover image bytes to data URI if present String? coverUrl = _coverUrl; + if (_coverImageBytes != null) { coverUrl = bytesToDataUri(_coverImageBytes!); } @@ -265,19 +294,11 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ); } - // ============================================================================ - // HELPERS - // ============================================================================ - String? _nullIfEmpty(String text) { final trimmed = text.trim(); return trimmed.isEmpty ? null : trimmed; } - // ============================================================================ - // BUILD - // ============================================================================ - @override Widget build(BuildContext context) { return Form( @@ -303,7 +324,10 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { onUrlChanged: (url) => setState(() => _coverUrl = url), onFileChanged: (bytes) => setState(() { _coverImageBytes = bytes; - if (bytes != null) _coverUrl = null; + + if (bytes != null) { + _coverUrl = null; + } }), coverWidth: 240, ), @@ -330,10 +354,6 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ); } - // ============================================================================ - // SECTION CARD - // ============================================================================ - Widget _buildSectionCard({required String? title, required List children}) { return Card( margin: const EdgeInsets.only(bottom: Spacing.xs), @@ -353,10 +373,6 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ); } - // ============================================================================ - // FORM SECTIONS - // ============================================================================ - Widget _buildBasicInfoSection() { return _buildSectionCard( title: 'Basic information', @@ -443,10 +459,6 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ); } - // ============================================================================ - // ISBN SECTION - // ============================================================================ - Widget _buildIsbnSection() { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -524,8 +536,4 @@ class _PhysicalBookContentState extends State<_PhysicalBookContent> { ], ); } - - // ============================================================================ - // ACTION BUTTONS - // ============================================================================ } diff --git a/app/lib/widgets/add_book/book_import_batch_item.dart b/app/lib/widgets/add_book/book_import_batch_item.dart index a196d004..8e1cfbe7 100644 --- a/app/lib/widgets/add_book/book_import_batch_item.dart +++ b/app/lib/widgets/add_book/book_import_batch_item.dart @@ -73,7 +73,9 @@ class BookImportBatchItem { if (status != BookImportBatchStatus.ready && status != BookImportBatchStatus.commitFailed) { throw StateError('Cannot start adding an item with status $status.'); } + final value = result; + if (value == null) { throw StateError('Cannot add an item without a processed result.'); } @@ -85,7 +87,9 @@ class BookImportBatchItem { if (status != BookImportBatchStatus.adding) { throw StateError('Cannot finish adding an item with status $status.'); } + final value = result; + if (value == null) { throw StateError('Cannot finish adding an item without a processed result.'); } @@ -97,7 +101,9 @@ class BookImportBatchItem { if (status != BookImportBatchStatus.adding) { throw StateError('Cannot fail adding an item with status $status.'); } + final value = result; + if (value == null) { throw StateError('Cannot fail adding an item without a processed result.'); } diff --git a/app/lib/widgets/add_book/book_import_controller.dart b/app/lib/widgets/add_book/book_import_controller.dart index b45f700d..a1f5cf72 100644 --- a/app/lib/widgets/add_book/book_import_controller.dart +++ b/app/lib/widgets/add_book/book_import_controller.dart @@ -89,38 +89,63 @@ class BookImportController extends ChangeNotifier { int get failureCount => _items.length - successCount; Future browse({bool append = false}) async { - if (_disposed || _isPicking) return; + if (_disposed || _isPicking) { + return; + } + _update(() { _isPicking = true; _pickerError = null; }); + try { final selectedFiles = await _pickFiles(); - if (_disposed || selectedFiles.isEmpty) return; + + if (_disposed || selectedFiles.isEmpty) { + return; + } + _update(() => _files = List.unmodifiable([if (append) ..._files, ...selectedFiles])); } catch (_) { - if (_disposed) return; + if (_disposed) { + return; + } + _update(() => _pickerError = 'Could not open the selected files. Please try again.'); } finally { - if (!_disposed) _update(() => _isPicking = false); + if (!_disposed) { + _update(() => _isPicking = false); + } } } void applyDroppedFiles(List files, {String? feedback}) { - if (_disposed) return; + if (_disposed) { + return; + } + _update(() { - if (files.isNotEmpty) _files = List.unmodifiable(files); + if (files.isNotEmpty) { + _files = List.unmodifiable(files); + } + _pickerError = feedback; }); } void removeFile(SelectedBookFile file) { - if (_disposed) return; + if (_disposed) { + return; + } + _update(() => _files = List.unmodifiable(_files.where((candidate) => !identical(candidate, file)))); } void clearSelection() { - if (_disposed) return; + if (_disposed) { + return; + } + _update(() { _files = const []; _pickerError = null; @@ -129,24 +154,37 @@ class BookImportController extends ChangeNotifier { void startImport() { final importFiles = readableFiles; - if (_disposed || importFiles.isEmpty) return; + + if (_disposed || importFiles.isEmpty) { + return; + } + _update(() { _phase = BookImportPhase.processing; + _items = List.generate( importFiles.length, (index) => BookImportBatchItem.queued(id: 'import-$index', file: importFiles[index]), growable: true, ); }); + for (final item in List.of(_items)) { unawaited(_startProcessing(item.id)); } } Future retryItem(String id) async { - if (_disposed || _isClosing) return; + if (_disposed || _isClosing) { + return; + } + final index = _indexOf(id); - if (index < 0) return; + + if (index < 0) { + return; + } + final item = _items[index]; if (item.status == BookImportBatchStatus.processingFailed) { @@ -160,6 +198,7 @@ class BookImportController extends ChangeNotifier { _phase = BookImportPhase.processing; _items[index] = item.startAdding(); }); + unawaited(_startCommit(id)); } } @@ -168,30 +207,50 @@ class BookImportController extends ChangeNotifier { if (_disposed || _isClosing || (_phase == BookImportPhase.processing && anyProcessing)) { return BookImportRemoveResult.ignored; } + final index = _indexOf(id); - if (index < 0) return BookImportRemoveResult.ignored; - final item = _items[index]; + if (index < 0) { + return BookImportRemoveResult.ignored; + } + + final item = _items[index]; final result = item.result; + if (result != null && item.status != BookImportBatchStatus.added) { final deleted = await _deleteTemporary(result.bookId); - if (!deleted) return BookImportRemoveResult.cleanupFailed; + + if (!deleted) { + return BookImportRemoveResult.cleanupFailed; + } + } + + if (_disposed) { + return BookImportRemoveResult.ignored; } - if (_disposed) return BookImportRemoveResult.ignored; + _update(() => _items.removeAt(index)); return BookImportRemoveResult.removed; } Future requestClose() { final inFlight = _closeFuture; - if (inFlight != null) return inFlight; + + if (inFlight != null) { + return inFlight; + } + final close = _close(); _closeFuture = close; + unawaited( close.whenComplete(() { - if (identical(_closeFuture, close)) _closeFuture = null; + if (identical(_closeFuture, close)) { + _closeFuture = null; + } }), ); + return close; } @@ -201,9 +260,14 @@ class BookImportController extends ChangeNotifier { Future _startTrackedOperation(String id, Future Function() operation) { final inFlight = _processingFutures[id]; - if (inFlight != null) return inFlight; + + if (inFlight != null) { + return inFlight; + } + final processing = operation(); _processingFutures[id] = processing; + unawaited( processing.whenComplete(() { if (identical(_processingFutures[id], processing)) { @@ -211,53 +275,72 @@ class BookImportController extends ChangeNotifier { } }), ); + return processing; } Future _process(String id) async { - if (_disposed || _isClosing) return true; + if (_disposed || _isClosing) { + return true; + } + final index = _indexOf(id); - if (index < 0) return true; + + if (index < 0) { + return true; + } final token = (_processingTokens[id] ?? 0) + 1; _processingTokens[id] = token; final processingItem = _items[index].startProcessing(); _update(() => _items[index] = processingItem); - final bytes = processingItem.file.bytes; + if (bytes == null) { - if (!_isCurrentProcessing(id, token)) return true; + if (!_isCurrentProcessing(id, token)) { + return true; + } + _update(() { final currentIndex = _indexOf(id); _items[currentIndex] = _items[currentIndex].processingFailed('Could not read this file.'); }); + _maybeTransitionToSummary(); return true; } BookImportResult result; + try { result = await _processor(bytes, processingItem.file.name); } catch (error) { - if (!_isCurrentProcessing(id, token)) return true; + if (!_isCurrentProcessing(id, token)) { + return true; + } + _update(() { final currentIndex = _indexOf(id); _items[currentIndex] = _items[currentIndex].processingFailed(_safeErrorMessage(error)); }); + _maybeTransitionToSummary(); return true; } if (_isClosing) { final deleted = await _deleteTemporary(result.bookId); + if (!deleted && !_disposed && _isMatchingProcessing(id, token)) { _update(() { final currentIndex = _indexOf(id); _items[currentIndex] = _items[currentIndex].processingSucceeded(result); }); } + return deleted; } + if (_disposed || !_isMatchingProcessing(id, token)) { return _deleteTemporary(result.bookId); } @@ -266,8 +349,10 @@ class BookImportController extends ChangeNotifier { final currentIndex = _indexOf(id); _items[currentIndex] = _items[currentIndex].processingSucceeded(result); }); + _update(() { final currentIndex = _indexOf(id); + if (currentIndex >= 0 && _items[currentIndex].status == BookImportBatchStatus.ready) { _items[currentIndex] = _items[currentIndex].startAdding(); } @@ -275,17 +360,32 @@ class BookImportController extends ChangeNotifier { try { final book = await _committer(result, processingItem.file.name); - if (_disposed) return true; + + if (_disposed) { + return true; + } + final currentIndex = _indexOf(id); - if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) return true; + + if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) { + return true; + } + _update(() { _items[currentIndex] = _items[currentIndex].added(); _addedBooks.add(book); }); } catch (error) { - if (_disposed) return true; + if (_disposed) { + return true; + } + final currentIndex = _indexOf(id); - if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) return true; + + if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) { + return true; + } + _update(() { _items[currentIndex] = _items[currentIndex].commitFailed(_safeErrorMessage(error)); }); @@ -297,50 +397,91 @@ class BookImportController extends ChangeNotifier { Future _commitItem(String id) async { final index = _indexOf(id); - if (index < 0) return true; + + if (index < 0) { + return true; + } + final item = _items[index]; - if (item.status != BookImportBatchStatus.adding || item.result == null) return true; + + if (item.status != BookImportBatchStatus.adding || item.result == null) { + return true; + } try { final book = await _committer(item.result!, item.file.name); - if (_disposed) return true; + + if (_disposed) { + return true; + } + final currentIndex = _indexOf(id); - if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) return true; + + if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) { + return true; + } + _update(() { _items[currentIndex] = _items[currentIndex].added(); _addedBooks.add(book); }); } catch (error) { - if (_disposed) return true; + if (_disposed) { + return true; + } + final currentIndex = _indexOf(id); - if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) return true; + + if (currentIndex < 0 || _items[currentIndex].status != BookImportBatchStatus.adding) { + return true; + } + _update(() { _items[currentIndex] = _items[currentIndex].commitFailed(_safeErrorMessage(error)); }); } + _maybeTransitionToSummary(); return true; } void _maybeTransitionToSummary() { - if (_disposed || _phase != BookImportPhase.processing || !allSettled) return; + if (_disposed || _phase != BookImportPhase.processing || !allSettled) { + return; + } + _update(() => _phase = BookImportPhase.summary); - if (_didComplete) return; + + if (_didComplete) { + return; + } + _didComplete = true; _onCompleted?.call(List.unmodifiable(_addedBooks)); } Future _deleteTemporary(String bookId) { - if (_cleanedBookIds.contains(bookId)) return Future.value(true); + if (_cleanedBookIds.contains(bookId)) { + return Future.value(true); + } + final inFlight = _cleanupFutures[bookId]; - if (inFlight != null) return inFlight; + + if (inFlight != null) { + return inFlight; + } + final cleanup = _performDelete(bookId); _cleanupFutures[bookId] = cleanup; + unawaited( cleanup.whenComplete(() { - if (identical(_cleanupFutures[bookId], cleanup)) _cleanupFutures.remove(bookId); + if (identical(_cleanupFutures[bookId], cleanup)) { + _cleanupFutures.remove(bookId); + } }), ); + return cleanup; } @@ -356,22 +497,36 @@ class BookImportController extends ChangeNotifier { } Future _close() async { - if (_disposed) return BookImportCloseResult.processingCleanupFailed; - if (_isClosing) return BookImportCloseResult.processingCleanupFailed; - _update(() => _isClosing = true); + if (_disposed) { + return BookImportCloseResult.processingCleanupFailed; + } + if (_isClosing) { + return BookImportCloseResult.processingCleanupFailed; + } + + _update(() => _isClosing = true); final processingResults = await Future.wait(List>.of(_processingFutures.values)); + if (processingResults.any((completed) => !completed)) { - if (!_disposed) _update(() => _isClosing = false); + if (!_disposed) { + _update(() => _isClosing = false); + } + return BookImportCloseResult.processingCleanupFailed; } final bookIds = _items.where((item) => item.hasTemporaryFile).map((item) => item.result!.bookId).toSet(); final cleanupResults = await Future.wait(bookIds.map(_deleteTemporary)); + if (cleanupResults.any((deleted) => !deleted)) { - if (!_disposed) _update(() => _isClosing = false); + if (!_disposed) { + _update(() => _isClosing = false); + } + return BookImportCloseResult.cleanupFailed; } + return BookImportCloseResult.closed; } @@ -380,7 +535,10 @@ class BookImportController extends ChangeNotifier { bool _isCurrentProcessing(String id, int token) => !_isClosing && _isMatchingProcessing(id, token); bool _isMatchingProcessing(String id, int token) { - if (_disposed || _processingTokens[id] != token) return false; + if (_disposed || _processingTokens[id] != token) { + return false; + } + final index = _indexOf(id); return index >= 0 && _items[index].status == BookImportBatchStatus.processing; } @@ -391,7 +549,10 @@ class BookImportController extends ChangeNotifier { } void _update(VoidCallback update) { - if (_disposed) return; + if (_disposed) { + return; + } + update(); notifyListeners(); } diff --git a/app/lib/widgets/add_book/book_import_drop_zone.dart b/app/lib/widgets/add_book/book_import_drop_zone.dart index df6565e8..62ad4064 100644 --- a/app/lib/widgets/add_book/book_import_drop_zone.dart +++ b/app/lib/widgets/add_book/book_import_drop_zone.dart @@ -39,13 +39,19 @@ class _BookImportDropZoneState extends State { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final supportsDrop = _supportsFileDrop(theme.platform) && MediaQuery.sizeOf(context).width >= Breakpoints.tablet; - if (!supportsDrop) return _buildFilePicker(context); + + if (!supportsDrop) { + return _buildFilePicker(context); + } + final isActive = _isDragging || _isFocused; - final backgroundColor = _isDragging - ? colorScheme.primaryContainer.withValues(alpha: 0.18) - : _isFocused - ? colorScheme.surfaceContainerHighest.withValues(alpha: 0.35) - : Colors.transparent; + + final backgroundColor = switch (_isDragging) { + true => colorScheme.primaryContainer.withValues(alpha: 0.18), + false when _isFocused => colorScheme.surfaceContainerHighest.withValues(alpha: 0.35), + false => Colors.transparent, + }; + final borderColor = isActive ? colorScheme.primary : colorScheme.outlineVariant; final surface = FocusableActionDetector( @@ -84,6 +90,7 @@ class _BookImportDropZoneState extends State { child: LayoutBuilder( builder: (context, constraints) { final minimumHeight = constraints.hasBoundedHeight ? constraints.maxHeight : 0.0; + return SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: minimumHeight), @@ -145,6 +152,7 @@ class _BookImportDropZoneState extends State { Widget _buildFilePicker(BuildContext context) { final theme = Theme.of(context); + return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( @@ -187,11 +195,16 @@ class _BookImportDropZoneState extends State { } void _browse() { - if (!_isBusy) widget.onBrowse(); + if (!_isBusy) { + widget.onBrowse(); + } } Future _readDroppedFiles(DropDoneDetails details) async { - if (_isBusy) return; + if (_isBusy) { + return; + } + final supportedItems = details.files.whereType().where(_isSupportedBook).toList(growable: false); final skippedFiles = supportedItems.length != details.files.length; @@ -207,7 +220,11 @@ class _BookImportDropZoneState extends State { } final files = await Future.wait(supportedItems.map(_readBookFile)); - if (!mounted) return; + + if (!mounted) { + return; + } + setState(() => _isReadingDrop = false); widget.onDroppedFiles(files, feedback: skippedFiles ? _skippedFilesMessage : null); } @@ -215,17 +232,23 @@ class _BookImportDropZoneState extends State { bool _isSupportedBook(DropItemFile item) { final name = item.name.toLowerCase(); final separator = name.lastIndexOf('.'); - if (separator < 0 || separator == name.length - 1) return false; + + if (separator < 0 || separator == name.length - 1) { + return false; + } + return bookImportNativeExtensions.contains(name.substring(separator + 1)); } Future _readBookFile(DropItemFile item) async { final bookmark = item.extraAppleBookmark; var hasSecurityScopedAccess = false; + try { if (bookmark != null && bookmark.isNotEmpty) { hasSecurityScopedAccess = await DesktopDrop.instance.startAccessingSecurityScopedResource(bookmark: bookmark); } + return SelectedBookFile(name: item.name, bytes: await item.readAsBytes()); } catch (_) { return SelectedBookFile(name: item.name, bytes: null); @@ -253,10 +276,14 @@ class _DashedRoundedBorderPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - if (size.isEmpty) return; + if (size.isEmpty) { + return; + } + final inset = strokeWidth / 2; final bounds = Rect.fromLTWH(inset, inset, size.width - strokeWidth, size.height - strokeWidth); final path = Path()..addRRect(RRect.fromRectAndRadius(bounds, Radius.circular(radius - inset))); + final paint = Paint() ..color = color ..style = PaintingStyle.stroke @@ -265,6 +292,7 @@ class _DashedRoundedBorderPainter extends CustomPainter { for (final metric in path.computeMetrics()) { var distance = 0.0; + while (distance < metric.length) { canvas.drawPath(metric.extractPath(distance, math.min(distance + 8, metric.length)), paint); distance += 14; diff --git a/app/lib/widgets/add_book/book_import_item_card.dart b/app/lib/widgets/add_book/book_import_item_card.dart index 9883b793..710e6637 100644 --- a/app/lib/widgets/add_book/book_import_item_card.dart +++ b/app/lib/widgets/add_book/book_import_item_card.dart @@ -22,7 +22,11 @@ class BookImportItemCard extends StatelessWidget { String get _displayTitle { final result = item.result; - if (result != null && result.title.isNotEmpty) return result.title; + + if (result != null && result.title.isNotEmpty) { + return result.title; + } + return item.file.name; } @@ -51,18 +55,18 @@ class BookImportItemCard extends StatelessWidget { duration: AppMotion.duration(context, const Duration(milliseconds: 250)), margin: const EdgeInsets.only(bottom: Spacing.xs), decoration: BoxDecoration( - color: _added - ? colorScheme.primaryContainer.withValues(alpha: 0.15) - : _failed - ? colorScheme.errorContainer.withValues(alpha: 0.15) - : colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + color: switch (_added) { + true => colorScheme.primaryContainer.withValues(alpha: 0.15), + false when _failed => colorScheme.errorContainer.withValues(alpha: 0.15), + false => colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + }, borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: _added - ? colorScheme.primary.withValues(alpha: 0.3) - : _failed - ? colorScheme.error.withValues(alpha: 0.3) - : colorScheme.outlineVariant, + color: switch (_added) { + true => colorScheme.primary.withValues(alpha: 0.3), + false when _failed => colorScheme.error.withValues(alpha: 0.3), + false => colorScheme.outlineVariant, + }, ), ), padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), @@ -124,11 +128,11 @@ class BookImportItemCard extends StatelessWidget { color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), borderRadius: BorderRadius.circular(AppRadius.md), border: Border.all( - color: _added - ? colorScheme.primary.withValues(alpha: 0.2) - : _failed - ? colorScheme.error.withValues(alpha: 0.2) - : colorScheme.outlineVariant, + color: switch (_added) { + true => colorScheme.primary.withValues(alpha: 0.2), + false when _failed => colorScheme.error.withValues(alpha: 0.2), + false => colorScheme.outlineVariant, + }, ), ), child: Row( @@ -248,11 +252,11 @@ class BookImportItemCard extends StatelessWidget { width: 40, height: 56, decoration: BoxDecoration( - color: _added - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : _failed - ? colorScheme.errorContainer.withValues(alpha: 0.3) - : colorScheme.surfaceContainerHighest, + color: switch (_added) { + true => colorScheme.primaryContainer.withValues(alpha: 0.3), + false when _failed => colorScheme.errorContainer.withValues(alpha: 0.3), + false => colorScheme.surfaceContainerHighest, + }, borderRadius: BorderRadius.circular(AppRadius.sm), ), child: Icon( diff --git a/app/lib/widgets/add_book/book_import_sheet.dart b/app/lib/widgets/add_book/book_import_sheet.dart index 26f61338..32e7bbf1 100644 --- a/app/lib/widgets/add_book/book_import_sheet.dart +++ b/app/lib/widgets/add_book/book_import_sheet.dart @@ -46,7 +46,11 @@ class BookImportSheet extends StatefulWidget { allowMultiple: true, withData: true, ); - if (result == null) return const []; + + if (result == null) { + return const []; + } + return result.files.map((file) => SelectedBookFile(name: file.name, bytes: file.bytes)).toList(); } @@ -61,6 +65,7 @@ class BookImportSheet extends StatefulWidget { final importService = processor == null || deleteBookFile == null ? context.read() : null; final effectiveProcessor = processor ?? importService!.importBook; final effectiveDeleter = deleteBookFile ?? importService!.deleteBookFile; + final effectiveCommitter = committer ?? (BookImportResult result, String filename) => _commitResult(context, result, filename); @@ -73,6 +78,7 @@ class BookImportSheet extends StatefulWidget { shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), builder: (sheetContext) { final effectivePickFiles = pickFiles ?? defaultPickFiles; + Widget buildSheet(ScrollController scrollController) => BookImportSheet( pickFiles: effectivePickFiles, processor: effectiveProcessor, @@ -82,16 +88,19 @@ class BookImportSheet extends StatefulWidget { onClose: () => Navigator.of(sheetContext).pop(), onCompleted: (books) { final count = books.length; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar(content: Text('$count ${count == 1 ? 'book' : 'books'} added to library')), ); }, ); + if (MediaQuery.sizeOf(sheetContext).width < Breakpoints.tablet) { // Import dismissal must go through the session's cleanup callback. return ExpandableBottomSheet(canClose: false, builder: (_, controller) => buildSheet(controller)); } + return DraggableScrollableSheet( initialChildSize: 0.6, minChildSize: 0.4, @@ -118,6 +127,7 @@ class _BookImportSheetState extends State { @override void initState() { super.initState(); + _controller = BookImportController( pickFiles: () => widget.pickFiles(), processor: (bytes, filename) => widget.processor(bytes, filename), @@ -138,7 +148,9 @@ class _BookImportSheetState extends State { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { - if (!didPop) unawaited(_requestClose()); + if (!didPop) { + unawaited(_requestClose()); + } }, child: ListenableBuilder( listenable: _controller, @@ -181,13 +193,19 @@ class _BookImportSheetState extends State { void _retryFailedItems() { for (final item in List.of(_controller.items)) { - if (item.canRetry) unawaited(_controller.retryItem(item.id)); + if (item.canRetry) { + unawaited(_controller.retryItem(item.id)); + } } } Future _removeItem(String id) async { final result = await _controller.removeItem(id); - if (!mounted || result != BookImportRemoveResult.cleanupFailed) return; + + if (!mounted || result != BookImportRemoveResult.cleanupFailed) { + return; + } + ScaffoldMessenger.maybeOf(context)?.showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), const SnackBar(content: Text('Could not remove the imported file.')), @@ -196,20 +214,32 @@ class _BookImportSheetState extends State { Future _requestClose() { final inFlight = _closeFuture; - if (inFlight != null) return inFlight; + + if (inFlight != null) { + return inFlight; + } + final close = _performClose(); _closeFuture = close; + unawaited( close.whenComplete(() { - if (identical(_closeFuture, close)) _closeFuture = null; + if (identical(_closeFuture, close)) { + _closeFuture = null; + } }), ); + return close; } Future _performClose() async { final result = await _controller.requestClose(); - if (!mounted) return; + + if (!mounted) { + return; + } + switch (result) { case BookImportCloseResult.closed: widget.onClose(); diff --git a/app/lib/widgets/add_book/book_import_sheet_sections.dart b/app/lib/widgets/add_book/book_import_sheet_sections.dart index 5e6c087e..db21f5ae 100644 --- a/app/lib/widgets/add_book/book_import_sheet_sections.dart +++ b/app/lib/widgets/add_book/book_import_sheet_sections.dart @@ -38,6 +38,7 @@ class BookImportSelectingSection extends StatelessWidget { @override Widget build(BuildContext context) { final hasFiles = files.isNotEmpty; + return AddBookSheetScaffold( title: 'Import books', footerPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), @@ -56,6 +57,7 @@ class BookImportSelectingSection extends StatelessWidget { Widget _buildBrowseOnly(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return Padding( padding: const EdgeInsets.all(Spacing.lg), child: Column( @@ -79,6 +81,7 @@ class BookImportSelectingSection extends StatelessWidget { Widget _buildFileList(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return ListView( shrinkWrap: context.findAncestorWidgetOfExactType() != null, controller: scrollController, @@ -148,6 +151,7 @@ class BookImportProcessingSection extends StatelessWidget { separatorBuilder: (_, _) => const SizedBox(height: Spacing.xs), itemBuilder: (context, index) { final item = items[index]; + return BookImportItemCard( key: ValueKey(item.id), item: item, @@ -187,6 +191,7 @@ class BookImportSummarySection extends StatelessWidget { @override Widget build(BuildContext context) { final hasFailures = failureCount > 0; + return AddBookSheetScaffold( title: 'Import complete', footerPadding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), @@ -285,13 +290,20 @@ class _FileSelectCard extends StatelessWidget { } String _formatSize(int bytes) { - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024) { + return '$bytes B'; + } + + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } IconData _iconForExtension(String name) { final extension = name.toLowerCase().split('.').last; + return switch (extension) { 'pdf' => Icons.picture_as_pdf, 'epub' || 'mobi' || 'azw3' => Icons.menu_book, diff --git a/app/lib/widgets/add_book/isbn_scanner_dialog.dart b/app/lib/widgets/add_book/isbn_scanner_dialog.dart index 90406aba..cb2042c2 100644 --- a/app/lib/widgets/add_book/isbn_scanner_dialog.dart +++ b/app/lib/widgets/add_book/isbn_scanner_dialog.dart @@ -21,6 +21,7 @@ class _IsbnScannerDialogState extends State { final MobileScannerController _controller = MobileScannerController( formats: [BarcodeFormat.ean13, BarcodeFormat.ean8], ); + bool _hasScanned = false; @override @@ -30,9 +31,15 @@ class _IsbnScannerDialogState extends State { } void _onDetect(BarcodeCapture capture) { - if (_hasScanned) return; + if (_hasScanned) { + return; + } + final barcode = capture.barcodes.firstOrNull; - if (barcode?.rawValue == null) return; + + if (barcode?.rawValue == null) { + return; + } _hasScanned = true; Navigator.of(context).pop(barcode!.rawValue); diff --git a/app/lib/widgets/annotations/annotation_action_sheet.dart b/app/lib/widgets/annotations/annotation_action_sheet.dart index fdda0bab..3ebd4db4 100644 --- a/app/lib/widgets/annotations/annotation_action_sheet.dart +++ b/app/lib/widgets/annotations/annotation_action_sheet.dart @@ -7,10 +7,6 @@ import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/themes/app_motion.dart'; -// ============================================================================= -// ANNOTATION NOTE SHEET -// ============================================================================= - /// Bottom sheet for editing an annotation's attached note. class AnnotationNoteSheet extends StatefulWidget { final Annotation annotation; @@ -68,7 +64,10 @@ class _AnnotationNoteSheetState extends State with Persiste : () async { final text = _controller.text.trim(); final saved = await persist(() => widget.onSave?.call(text)); - if (saved && context.mounted) Navigator.pop(context, text); + + if (saved && context.mounted) { + Navigator.pop(context, text); + } }, ), body: TextField( @@ -87,10 +86,6 @@ class _AnnotationNoteSheetState extends State with Persiste } } -// ============================================================================= -// DELETE ANNOTATION DIALOG -// ============================================================================= - /// Confirmation dialog for deleting an annotation. class DeleteAnnotationDialog { /// Show the delete confirmation dialog. Returns true if confirmed. @@ -111,6 +106,7 @@ class DeleteAnnotationDialog { ], ), ); + return result ?? false; } } diff --git a/app/lib/widgets/auth/auth_continue_button.dart b/app/lib/widgets/auth/auth_continue_button.dart index eeb24844..d2d7dd0f 100644 --- a/app/lib/widgets/auth/auth_continue_button.dart +++ b/app/lib/widgets/auth/auth_continue_button.dart @@ -13,7 +13,6 @@ class AuthContinueButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final buttonHeight = isDesktop ? ComponentSizes.buttonHeightDesktop : ComponentSizes.buttonHeightMobile; return ElevatedButton( diff --git a/app/lib/widgets/auth/auth_page_layouts.dart b/app/lib/widgets/auth/auth_page_layouts.dart index 72a7d330..ef9a5105 100644 --- a/app/lib/widgets/auth/auth_page_layouts.dart +++ b/app/lib/widgets/auth/auth_page_layouts.dart @@ -177,7 +177,6 @@ class _DesktopAuthLayoutState extends State { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final isSwapped = DesktopAuthLayout._isSwapped; - final hero = Expanded(flex: 6, child: AuthHeroPanel(isDark: isDark)); final form = Expanded(flex: 4, child: _buildFormPanel(theme)); diff --git a/app/lib/widgets/auth/curved_bottom_clipper.dart b/app/lib/widgets/auth/curved_bottom_clipper.dart index 0b9aef64..38d4dcb0 100644 --- a/app/lib/widgets/auth/curved_bottom_clipper.dart +++ b/app/lib/widgets/auth/curved_bottom_clipper.dart @@ -30,7 +30,6 @@ class CurvedBottomClipper extends CustomClipper { // Close the path path.close(); - return path; } @@ -49,7 +48,6 @@ class WaveBottomClipper extends CustomClipper { @override Path getClip(Size size) { final path = Path(); - path.lineTo(0, 0); path.lineTo(0, size.height - waveHeight); @@ -58,10 +56,8 @@ class WaveBottomClipper extends CustomClipper { // Second curve (center to right) path.quadraticBezierTo(size.width * 0.75, size.height - waveHeight * 2, size.width, size.height - waveHeight); - path.lineTo(size.width, 0); path.close(); - return path; } diff --git a/app/lib/widgets/book/book_annotations.dart b/app/lib/widgets/book/book_annotations.dart index 57da64c0..0e754818 100644 --- a/app/lib/widgets/book/book_annotations.dart +++ b/app/lib/widgets/book/book_annotations.dart @@ -82,6 +82,7 @@ class _BookAnnotationsState extends State { if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); + result = result.where((annotation) { return annotation.highlightText.toLowerCase().contains(query) || (annotation.note?.toLowerCase().contains(query) ?? false) || @@ -89,16 +90,16 @@ class _BookAnnotationsState extends State { }).toList(); } - result.sort((a, b) { + result.sort((left, right) { switch (_sortOption) { case _AnnotationSort.dateNewest: - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); case _AnnotationSort.dateOldest: - return a.createdAt.compareTo(b.createdAt); + return left.createdAt.compareTo(right.createdAt); case _AnnotationSort.position: - return a.location.pageNumber.compareTo(b.location.pageNumber); + return left.location.pageNumber.compareTo(right.location.pageNumber); case _AnnotationSort.color: - return a.color.index.compareTo(b.color.index); + return left.color.index.compareTo(right.color.index); } }); @@ -125,7 +126,10 @@ class _BookAnnotationsState extends State { return EmptyAnnotationsState(isPhysical: widget.isPhysical, onAddAnnotation: widget.onAddAnnotation); } - if (isDesktop) return _buildDesktopLayout(context); + if (isDesktop) { + return _buildDesktopLayout(context); + } + return _buildMobileLayout(context); } @@ -241,6 +245,7 @@ class _BookAnnotationsState extends State { separatorBuilder: (_, _) => SizedBox(height: separatorHeight), itemBuilder: (context, index) { final annotation = annotations[index]; + return AnnotationCard( annotation: annotation, showActionMenu: showActionMenu, diff --git a/app/lib/widgets/book/book_bookmarks.dart b/app/lib/widgets/book/book_bookmarks.dart index c0d6d999..5c82c554 100644 --- a/app/lib/widgets/book/book_bookmarks.dart +++ b/app/lib/widgets/book/book_bookmarks.dart @@ -52,6 +52,7 @@ class _BookBookmarksState extends State { if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); + result = result.where((bookmark) { return bookmark.displayLocation.toLowerCase().contains(query) || (bookmark.note?.toLowerCase().contains(query) ?? false) || @@ -59,14 +60,14 @@ class _BookBookmarksState extends State { }).toList(); } - result.sort((a, b) { + result.sort((left, right) { switch (_sortOption) { case _BookmarkSort.dateNewest: - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); case _BookmarkSort.dateOldest: - return a.createdAt.compareTo(b.createdAt); + return left.createdAt.compareTo(right.createdAt); case _BookmarkSort.position: - return a.position.compareTo(b.position); + return left.position.compareTo(right.position); } }); @@ -93,7 +94,10 @@ class _BookBookmarksState extends State { return EmptyBookmarksState(isPhysical: widget.isPhysical, onAddBookmark: widget.onAddBookmark); } - if (isDesktop) return _buildDesktopLayout(context); + if (isDesktop) { + return _buildDesktopLayout(context); + } + return _buildMobileLayout(context); } @@ -227,6 +231,7 @@ class _BookBookmarksState extends State { separatorBuilder: (_, _) => SizedBox(height: separatorHeight), itemBuilder: (context, index) { final bookmark = bookmarks[index]; + return BookmarkListItem( bookmark: bookmark, bookTitle: widget.bookTitle, diff --git a/app/lib/widgets/book/book_details.dart b/app/lib/widgets/book/book_details.dart index d1cbb684..505f8eb1 100644 --- a/app/lib/widgets/book/book_details.dart +++ b/app/lib/widgets/book/book_details.dart @@ -32,6 +32,7 @@ class _BookDetailsState extends State { if (isDesktop) { return _buildDesktopLayout(context); } + return _buildMobileLayout(context); } diff --git a/app/lib/widgets/book/book_notes.dart b/app/lib/widgets/book/book_notes.dart index daeeed29..34ba4d06 100644 --- a/app/lib/widgets/book/book_notes.dart +++ b/app/lib/widgets/book/book_notes.dart @@ -73,6 +73,7 @@ class _BookNotesState extends State { if (_searchQuery.isNotEmpty) { final query = _searchQuery.toLowerCase(); + result = result.where((note) { return note.title.toLowerCase().contains(query) || note.content.toLowerCase().contains(query) || @@ -80,14 +81,14 @@ class _BookNotesState extends State { }).toList(); } - result.sort((a, b) { + result.sort((left, right) { switch (_sortOption) { case _NoteSort.dateNewest: - return b.createdAt.compareTo(a.createdAt); + return right.createdAt.compareTo(left.createdAt); case _NoteSort.dateOldest: - return a.createdAt.compareTo(b.createdAt); + return left.createdAt.compareTo(right.createdAt); case _NoteSort.title: - return a.title.toLowerCase().compareTo(b.title.toLowerCase()); + return left.title.toLowerCase().compareTo(right.title.toLowerCase()); } }); @@ -114,7 +115,10 @@ class _BookNotesState extends State { return EmptyNotesState(onAddNote: widget.onAddNote); } - if (isDesktop) return _buildDesktopLayout(context); + if (isDesktop) { + return _buildDesktopLayout(context); + } + return _buildMobileLayout(context); } @@ -243,6 +247,7 @@ class _BookNotesState extends State { separatorBuilder: (_, _) => SizedBox(height: separatorHeight), itemBuilder: (context, index) { final note = notes[index]; + return NoteCard( note: note, showActionMenu: showActionMenu, diff --git a/app/lib/widgets/book/cover_loading_placeholder.dart b/app/lib/widgets/book/cover_loading_placeholder.dart index 73f9f6c4..70177bd8 100644 --- a/app/lib/widgets/book/cover_loading_placeholder.dart +++ b/app/lib/widgets/book/cover_loading_placeholder.dart @@ -19,12 +19,16 @@ class CoverLoadingPlaceholder extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 72 || constraints.maxHeight < 96; + final icon = Icon( Icons.auto_stories_rounded, size: compact ? 22 : 36, color: colorScheme.onSurfaceVariant.withValues(alpha: 0.75), ); - if (compact) return Center(child: icon); + + if (compact) { + return Center(child: icon); + } return Center( child: Column( diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index c3fb10a8..354ca9f5 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -62,6 +62,7 @@ class _PrivateBookCoverState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + if (!_hasInjectedLoaderForCurrentSource) { _configureProviderLoader(subscribe: true); } @@ -70,6 +71,7 @@ class _PrivateBookCoverState extends State { @override void didUpdateWidget(covariant CoverImage oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl || oldWidget.mediaId != widget.mediaId || oldWidget.bookId != widget.bookId || @@ -81,6 +83,7 @@ class _PrivateBookCoverState extends State { _localCoverProvider = null; _loadKey = null; _configureInjectedLoader(); + if (!_hasInjectedLoaderForCurrentSource) { _configureProviderLoader(); } @@ -88,14 +91,25 @@ class _PrivateBookCoverState extends State { } void _configureInjectedLoader() { - if (_hasPublicUrl) return; + if (_hasPublicUrl) { + return; + } final mediaId = _usableMediaId; + if (mediaId != null) { final loader = widget.loadPrivateCover; - if (loader == null) return; + + if (loader == null) { + return; + } + final key = 'injected-media:$mediaId'; - if (_loadKey == key) return; + + if (_loadKey == key) { + return; + } + _loadKey = key; _coverFuture = loader(mediaId); return; @@ -103,9 +117,17 @@ class _PrivateBookCoverState extends State { final bookId = _usableBookId; final loader = widget.loadLocalBookCover; - if (bookId == null || loader == null) return; + + if (bookId == null || loader == null) { + return; + } + final key = 'injected-local:$bookId'; - if (_loadKey == key) return; + + if (_loadKey == key) { + return; + } + _loadKey = key; _coverFuture = loader(bookId); } @@ -114,11 +136,14 @@ class _PrivateBookCoverState extends State { try { final authProvider = subscribe ? context.watch() : context.read(); final user = authProvider.user; + final needsAccountScope = user != null && !authProvider.isOfflineMode && (_usableMediaId == null || authProvider.isSignedIn); + final syncSettings = needsAccountScope ? (subscribe ? context.watch() : context.read()) : null; + _configureProviderLoaderFromContext(authProvider, syncSettings); } on ProviderNotFoundException { // Standalone cover surfaces can still render their placeholder. @@ -126,10 +151,13 @@ class _PrivateBookCoverState extends State { } void _configureProviderLoaderFromContext(AuthProvider authProvider, SyncSettingsProvider? syncSettings) { - if (_hasPublicUrl) return; + if (_hasPublicUrl) { + return; + } final mediaId = _usableMediaId; final user = authProvider.user; + if (mediaId != null) { if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) { _clearProviderLoader(); @@ -140,24 +168,34 @@ class _PrivateBookCoverState extends State { _clearProviderLoader(); return; } + final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); final bookId = _usableBookId; + if (bookId != null) { final key = '${scope.persistenceKey}:${CoverStorageBucket.pending.name}:$bookId'; - if (_loadKey == key) return; + + if (_loadKey == key) { + return; + } final importService = context.read(); final cacheService = context.read(); final pendingLoader = widget.loadPendingBookCover ?? importService.getPendingCoverFile; _loadKey = key; _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( scopeKey: scope.persistenceKey, bucket: CoverStorageBucket.pending, fileId: bookId, loadBytes: () async { final pending = await pendingLoader(scope, bookId); - if (pending != null && pending.isNotEmpty) return pending; + + if (pending != null && pending.isNotEmpty) { + return pending; + } + return cacheService.ensureCoverCached( scope: scope, mediaId: mediaId, @@ -167,16 +205,21 @@ class _PrivateBookCoverState extends State { ); }, ); + return; } final key = '${scope.persistenceKey}:${CoverStorageBucket.cached.name}:$mediaId'; - if (_loadKey == key) return; + + if (_loadKey == key) { + return; + } final importService = context.read(); final cacheService = context.read(); _loadKey = key; _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( scopeKey: scope.persistenceKey, bucket: CoverStorageBucket.cached, @@ -189,38 +232,54 @@ class _PrivateBookCoverState extends State { downloadMedia: authProvider.downloadMedia, ), ); + return; } final bookId = _usableBookId; - if (bookId == null) return; + + if (bookId == null) { + return; + } if (!authProvider.isOfflineMode && user != null) { if (syncSettings == null) { _clearProviderLoader(); return; } + final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); final key = '${scope.persistenceKey}:${CoverStorageBucket.pending.name}:$bookId'; - if (_loadKey == key) return; + + if (_loadKey == key) { + return; + } + _loadKey = key; final loader = widget.loadPendingBookCover ?? context.read().getPendingCoverFile; _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( scopeKey: scope.persistenceKey, bucket: CoverStorageBucket.pending, fileId: bookId, loadBytes: () => loader(scope, bookId), ); + return; } final guestScopeKey = MediaStorageScope.localGuest.persistenceKey; final key = '$guestScopeKey:${CoverStorageBucket.guestBooks.name}:$bookId'; - if (_loadKey == key) return; + + if (_loadKey == key) { + return; + } + _loadKey = key; final loader = widget.loadGuestBookCover ?? context.read().getGuestCoverFile; _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( scopeKey: guestScopeKey, bucket: CoverStorageBucket.guestBooks, @@ -241,9 +300,18 @@ class _PrivateBookCoverState extends State { String? get _usableBookId => widget.bookId == null || widget.bookId!.isEmpty ? null : widget.bookId; bool get _hasInjectedLoaderForCurrentSource { - if (_hasPublicUrl) return true; - if (_usableMediaId != null) return widget.loadPrivateCover != null; - if (_usableBookId != null) return widget.loadLocalBookCover != null; + if (_hasPublicUrl) { + return true; + } + + if (_usableMediaId != null) { + return widget.loadPrivateCover != null; + } + + if (_usableBookId != null) { + return widget.loadLocalBookCover != null; + } + return true; } @@ -256,6 +324,7 @@ class _PrivateBookCoverState extends State { if (_hasInlineDataUri) { try { final data = Uri.parse(widget.imageUrl!).data; + if (data != null && data.mimeType.startsWith('image/')) { final bytes = data.contentAsBytes(); return Image.memory(bytes, fit: widget.fit, errorBuilder: (_, _, _) => widget.placeholder); @@ -263,6 +332,7 @@ class _PrivateBookCoverState extends State { } on FormatException { return widget.placeholder; } + return widget.placeholder; } @@ -278,13 +348,17 @@ class _PrivateBookCoverState extends State { } final localCoverProvider = _localCoverProvider; + if (localCoverProvider != null) { return Image( image: localCoverProvider, fit: widget.fit, gaplessPlayback: true, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; + if (wasSynchronouslyLoaded || frame != null) { + return child; + } + return const CoverLoadingPlaceholder(); }, errorBuilder: (_, _, _) => widget.placeholder, @@ -292,7 +366,10 @@ class _PrivateBookCoverState extends State { } final coverFuture = _coverFuture; - if (coverFuture == null) return widget.placeholder; + + if (coverFuture == null) { + return widget.placeholder; + } return FutureBuilder( future: coverFuture, @@ -300,11 +377,17 @@ class _PrivateBookCoverState extends State { if (snapshot.connectionState != ConnectionState.done) { return const CoverLoadingPlaceholder(); } + final bytes = snapshot.data; + if (bytes != null) { return Image.memory(bytes, fit: widget.fit, errorBuilder: (_, _, _) => widget.placeholder); } - if (snapshot.hasError) return widget.placeholder; + + if (snapshot.hasError) { + return widget.placeholder; + } + return widget.placeholder; }, ); diff --git a/app/lib/widgets/book_details/annotation_action_sheet.dart b/app/lib/widgets/book_details/annotation_action_sheet.dart index a64387cd..d69f4c42 100644 --- a/app/lib/widgets/book_details/annotation_action_sheet.dart +++ b/app/lib/widgets/book_details/annotation_action_sheet.dart @@ -27,6 +27,7 @@ class AnnotationActionSheet extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( header: Text( annotation.location.shortLocation, diff --git a/app/lib/widgets/book_details/annotation_dialog.dart b/app/lib/widgets/book_details/annotation_dialog.dart index 782215e7..5562aebc 100644 --- a/app/lib/widgets/book_details/annotation_dialog.dart +++ b/app/lib/widgets/book_details/annotation_dialog.dart @@ -68,8 +68,13 @@ class _AnnotationDialogState extends State with PersistentSave } Future _save() async { - if (isSaving) return; - if (!(_formKey.currentState?.validate() ?? false)) return; + if (isSaving) { + return; + } + + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } final page = int.parse(_pageController.text); final chapter = _chapterController.text.trim(); @@ -90,8 +95,12 @@ class _AnnotationDialogState extends State with PersistentSave createdAt: widget.existingAnnotation?.createdAt ?? DateTime.now(), updatedAt: _isEditing ? DateTime.now() : null, ); + final saved = await persist(() => widget.onSave?.call(annotation)); - if (saved && mounted) Navigator.of(context).pop(annotation); + + if (saved && mounted) { + Navigator.of(context).pop(annotation); + } } @override @@ -128,6 +137,7 @@ class _AnnotationDialogState extends State with PersistentSave if (value == null || value.trim().isEmpty) { return 'Please enter the highlighted text'; } + return null; }, ), @@ -143,10 +153,13 @@ class _AnnotationDialogState extends State with PersistentSave if (value == null || value.trim().isEmpty) { return 'Please enter a page number'; } + final page = int.tryParse(value); + if (page == null || page < 1) { return 'Enter a valid page number'; } + return null; }, ), @@ -182,6 +195,7 @@ class _AnnotationDialogState extends State with PersistentSave spacing: Spacing.sm, children: HighlightColor.values.map((color) { final isSelected = color == _selectedColor; + return GestureDetector( key: Key('annotation-color-${color.name}'), onTap: () => setState(() => _selectedColor = color), diff --git a/app/lib/widgets/book_details/book_action_buttons.dart b/app/lib/widgets/book_details/book_action_buttons.dart index d24653be..d9adb302 100644 --- a/app/lib/widgets/book_details/book_action_buttons.dart +++ b/app/lib/widgets/book_details/book_action_buttons.dart @@ -33,11 +33,13 @@ class BookActionButtons extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final actionStyle = bookDetailsActionStyle(context); final iconStyle = bookDetailsActionStyle(context, iconOnly: true); - final normalReadingLabel = book.progress > 0 - ? 'Continue' - : isDesktop - ? 'Start reading' - : 'Read'; + + final normalReadingLabel = switch (book.progress > 0) { + true => 'Continue', + false when isDesktop => 'Start reading', + false => 'Read', + }; + final digitalLabel = switch (readingActionState) { BookReadingActionState.ready || BookReadingActionState.download => normalReadingLabel, BookReadingActionState.checking => 'Checking file…', @@ -46,8 +48,10 @@ class BookActionButtons extends StatelessWidget { BookReadingActionState.downloading => 'Downloading…', BookReadingActionState.unavailable => 'File unavailable', }; + final canUseDigitalAction = readingActionState == BookReadingActionState.ready || readingActionState == BookReadingActionState.download; + final digitalIcon = switch (readingActionState) { BookReadingActionState.ready => Icon(book.progress > 0 ? Icons.play_arrow : Icons.menu_book), BookReadingActionState.download => const Icon(Icons.download_outlined), diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index ce30c020..e03771a4 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -37,6 +37,7 @@ class BookHeader extends StatelessWidget { if (isDesktop) { return _buildDesktopHeader(context); } + return _buildMobileHeader(context); } @@ -219,6 +220,7 @@ class _ReadingError extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: Spacing.xs, diff --git a/app/lib/widgets/book_details/book_info_grid.dart b/app/lib/widgets/book_details/book_info_grid.dart index 44045b9e..26e0593c 100644 --- a/app/lib/widgets/book_details/book_info_grid.dart +++ b/app/lib/widgets/book_details/book_info_grid.dart @@ -19,7 +19,6 @@ class BookInfoGrid extends StatelessWidget { List<_InfoEntry> _buildEntries() { final entries = <_InfoEntry>[]; - entries.add(_InfoEntry('Format', book.formatLabel)); if (book.totalPages != null) { @@ -49,6 +48,7 @@ class BookInfoGrid extends StatelessWidget { final seriesValue = book.seriesNumber != null ? '${book.seriesName} #${_formatSeriesNumber(book.seriesNumber!)}' : book.seriesName!; + entries.add(_InfoEntry('Series', seriesValue)); } else if (book.seriesNumber != null) { entries.add(_InfoEntry('Series', '#${_formatSeriesNumber(book.seriesNumber!)}')); @@ -74,6 +74,7 @@ class BookInfoGrid extends StatelessWidget { final lentValue = book.lentAt != null ? '${book.lentTo!} (since ${DateFormat.yMMMd().format(book.lentAt!)})' : book.lentTo!; + entries.add(_InfoEntry('Lent to', lentValue)); } diff --git a/app/lib/widgets/book_details/bookmark_dialog.dart b/app/lib/widgets/book_details/bookmark_dialog.dart index e82d6187..d9fc6361 100644 --- a/app/lib/widgets/book_details/bookmark_dialog.dart +++ b/app/lib/widgets/book_details/bookmark_dialog.dart @@ -71,8 +71,13 @@ class _BookmarkDialogState extends State with PersistentSave _save() async { - if (isSaving) return; - if (!(_formKey.currentState?.validate() ?? false)) return; + if (isSaving) { + return; + } + + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } final page = int.parse(_pageController.text); final position = widget.pageCount != null ? (page / widget.pageCount!).clamp(0.0, 1.0) : 0.0; @@ -89,13 +94,18 @@ class _BookmarkDialogState extends State with PersistentSave widget.onSave?.call(bookmark)); - if (saved && mounted) Navigator.of(context).pop(bookmark); + + if (saved && mounted) { + Navigator.of(context).pop(bookmark); + } } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( title: _isEditing ? 'Edit bookmark' : 'New bookmark', onClose: () => Navigator.of(context).pop(), @@ -127,13 +137,17 @@ class _BookmarkDialogState extends State with PersistentSave widget.pageCount!) { return 'Page exceeds total pages (${widget.pageCount})'; } + return null; }, ), @@ -164,6 +178,7 @@ class _BookmarkDialogState extends State with PersistentSave setState(() => _selectedColor = hex), child: Container( diff --git a/app/lib/widgets/book_details/note_action_sheet.dart b/app/lib/widgets/book_details/note_action_sheet.dart index 61faead1..9f14a765 100644 --- a/app/lib/widgets/book_details/note_action_sheet.dart +++ b/app/lib/widgets/book_details/note_action_sheet.dart @@ -27,6 +27,7 @@ class NoteActionSheet extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( header: Text( note.title, @@ -72,6 +73,7 @@ class DeleteNoteDialog extends StatelessWidget { context: context, builder: (context) => DeleteNoteDialog(note: note), ); + return result ?? false; } diff --git a/app/lib/widgets/book_details/note_dialog.dart b/app/lib/widgets/book_details/note_dialog.dart index 18e6f92b..bbe0d5ed 100644 --- a/app/lib/widgets/book_details/note_dialog.dart +++ b/app/lib/widgets/book_details/note_dialog.dart @@ -89,6 +89,7 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< void _addTag() { final tag = _tagController.text.trim().toLowerCase(); + if (tag.isNotEmpty && !_tags.contains(tag)) { setState(() { _tags.add(tag); @@ -104,7 +105,10 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< } Future _save() async { - if (isSaving) return; + if (isSaving) { + return; + } + if (_formKey.currentState?.validate() ?? false) { final note = Note( id: widget.existingNote?.id ?? const Uuid().v4(), @@ -117,14 +121,19 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< createdAt: widget.existingNote?.createdAt ?? DateTime.now(), updatedAt: isEditing ? DateTime.now() : null, ); + final saved = await persist(() => widget.onSave?.call(note)); - if (saved && mounted) Navigator.of(context).pop(note); + + if (saved && mounted) { + Navigator.of(context).pop(note); + } } } @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( key: const Key('note-bottom-sheet'), title: isEditing ? 'Edit note' : 'New note', @@ -163,6 +172,7 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< if (value == null || value.trim().isEmpty) { return 'Please enter a title'; } + return null; }, ), @@ -194,6 +204,7 @@ class _BottomSheetNoteState extends State<_BottomSheetNote> with PersistentSave< if (value == null || value.trim().isEmpty) { return 'Please enter some content'; } + return null; }, ), diff --git a/app/lib/widgets/book_details/update_progress_sheet.dart b/app/lib/widgets/book_details/update_progress_sheet.dart index a5a88e0d..6a6a4fe5 100644 --- a/app/lib/widgets/book_details/update_progress_sheet.dart +++ b/app/lib/widgets/book_details/update_progress_sheet.dart @@ -56,9 +56,14 @@ class _UpdateProgressSheetState extends State { double _calculatePosition() { if (_hasPageCount) { final page = int.tryParse(_pageController.text) ?? 0; - if (page <= 0) return 0.0; + + if (page <= 0) { + return 0.0; + } + return (page / widget.book.pageCount!).clamp(0.0, 1.0); } + return _sliderValue; } @@ -66,6 +71,7 @@ class _UpdateProgressSheetState extends State { if (_hasPageCount) { return (int.tryParse(_pageController.text) ?? 0).clamp(0, widget.book.pageCount!); } + return (_sliderValue * (widget.book.pageCount ?? 100)).round(); } diff --git a/app/lib/widgets/book_edit/cover_image_picker.dart b/app/lib/widgets/book_edit/cover_image_picker.dart index 036fed61..f5296e35 100644 --- a/app/lib/widgets/book_edit/cover_image_picker.dart +++ b/app/lib/widgets/book_edit/cover_image_picker.dart @@ -50,6 +50,7 @@ class _CoverImagePickerState extends State { _imageUrl = widget.initialUrl; _imageBytes = widget.initialBytes; _urlController.text = widget.initialUrl ?? ''; + _showUrlInput = widget.initialUrl?.isNotEmpty == true && widget.initialBytes == null && @@ -60,24 +61,29 @@ class _CoverImagePickerState extends State { void didUpdateWidget(CoverImagePicker oldWidget) { super.didUpdateWidget(oldWidget); // Sync URL when parent provides a new one (e.g., from metadata fetch) + if (widget.initialUrl != oldWidget.initialUrl) { setState(() { _imageUrl = widget.initialUrl; + if (widget.initialUrl != null && !widget.initialUrl!.startsWith('data:')) { _urlController.text = widget.initialUrl!; } else { _urlController.clear(); } // Clear bytes if URL changed from external source + if (widget.initialUrl != null && widget.initialUrl!.isNotEmpty) { _imageBytes = null; } }); } // Sync bytes when parent provides new ones + if (widget.initialBytes != oldWidget.initialBytes) { setState(() { _imageBytes = widget.initialBytes; + if (widget.initialBytes != null) { _imageUrl = null; _urlController.clear(); @@ -223,7 +229,10 @@ class _CoverImagePickerState extends State { fit: BoxFit.cover, errorBuilder: (_, e, s) => _buildPlaceholder(context), loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; + if (loadingProgress == null) { + return child; + } + return const Center(child: AppCircularProgressIndicator()); }, ), @@ -261,6 +270,7 @@ class _CoverImagePickerState extends State { Widget _buildPlaceholder(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -299,6 +309,7 @@ class _CoverImagePickerState extends State { _error = 'Image must be less than 5MB'; _isLoading = false; }); + return; } @@ -315,7 +326,7 @@ class _CoverImagePickerState extends State { } else { setState(() => _isLoading = false); } - } catch (e) { + } catch (error) { setState(() { _error = 'Failed to pick image'; _isLoading = false; @@ -325,14 +336,17 @@ class _CoverImagePickerState extends State { void _onUrlChanged(String value) { final url = value.trim(); + setState(() { _imageUrl = url.isEmpty ? null : url; + if (url.isNotEmpty) { _imageBytes = null; } }); widget.onUrlChanged(url.isEmpty ? null : url); + if (url.isNotEmpty) { widget.onFileChanged(null); } @@ -340,7 +354,10 @@ class _CoverImagePickerState extends State { void _applyUrl() { final url = _urlController.text.trim(); - if (url.isEmpty) return; + + if (url.isEmpty) { + return; + } setState(() { _imageUrl = url; diff --git a/app/lib/widgets/book_form/book_date_field.dart b/app/lib/widgets/book_form/book_date_field.dart index cef8623e..6fd7aa94 100644 --- a/app/lib/widgets/book_form/book_date_field.dart +++ b/app/lib/widgets/book_form/book_date_field.dart @@ -52,6 +52,7 @@ class BookDateField extends StatelessWidget { firstDate: DateTime(1000), lastDate: DateTime.now().add(const Duration(days: 365)), ); + if (picked != null) { controller.text = DateFormat.yMMMMd().format(picked); onChanged(picked); diff --git a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart index 8b11d473..6cb9825b 100644 --- a/app/lib/widgets/bookmarks/bookmark_action_sheet.dart +++ b/app/lib/widgets/bookmarks/bookmark_action_sheet.dart @@ -7,10 +7,6 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/shared/persistent_save.dart'; import 'package:papyrus/themes/app_motion.dart'; -// ============================================================================= -// BOOKMARK ACTION SHEET (action chooser) -// ============================================================================= - /// Result of bookmark action sheet selection. enum BookmarkAction { editNote, changeColor, delete } @@ -34,6 +30,7 @@ class BookmarkActionSheet extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( header: Text( bookmark.displayLocation, @@ -73,10 +70,6 @@ class BookmarkActionSheet extends StatelessWidget { } } -// ============================================================================= -// COLOR NAMES -// ============================================================================= - /// Color name mapping for display. const _colorNames = { '#FF5722': 'Orange', @@ -88,10 +81,6 @@ const _colorNames = { '#FFC107': 'Amber', }; -// ============================================================================= -// BOOKMARK NOTE SHEET -// ============================================================================= - /// Bottom sheet for editing a bookmark's note. class BookmarkNoteSheet extends StatefulWidget { final Bookmark bookmark; @@ -150,7 +139,10 @@ class _BookmarkNoteSheetState extends State with PersistentSa : () async { final text = _controller.text.trim(); final saved = await persist(() => widget.onSave?.call(text)); - if (saved && context.mounted) Navigator.pop(context, text); + + if (saved && context.mounted) { + Navigator.pop(context, text); + } }, ), body: TextField( @@ -169,10 +161,6 @@ class _BookmarkNoteSheetState extends State with PersistentSa } } -// ============================================================================= -// BOOKMARK COLOR SHEET -// ============================================================================= - /// Bottom sheet for selecting a bookmark color. class BookmarkColorSheet extends StatefulWidget { final Bookmark bookmark; @@ -206,6 +194,7 @@ class _BookmarkColorSheetState extends State with Persistent @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( header: Text('Change color', style: Theme.of(context).textTheme.titleMedium), body: Column( @@ -224,7 +213,10 @@ class _BookmarkColorSheetState extends State with Persistent ? null : () async { final saved = await persist(() => widget.onSave?.call(hex)); - if (saved && context.mounted) Navigator.pop(context, hex); + + if (saved && context.mounted) { + Navigator.pop(context, hex); + } }, child: Container( width: 48, @@ -273,10 +265,6 @@ class _BookmarkColorSheetState extends State with Persistent } } -// ============================================================================= -// DELETE BOOKMARK DIALOG -// ============================================================================= - /// Confirmation dialog for deleting a bookmark. class DeleteBookmarkDialog { /// Show the delete confirmation dialog. Returns true if confirmed. @@ -291,6 +279,7 @@ class DeleteBookmarkDialog { context: context, builder: (context) => _DeleteBookmarkConfirmation(bookmark: bookmark, bookTitle: bookTitle, onDelete: onDelete), ); + return result ?? false; } } @@ -320,7 +309,10 @@ class _DeleteBookmarkConfirmationState extends State<_DeleteBookmarkConfirmation ? null : () async { final saved = await persist(() => widget.onDelete?.call()); - if (saved && context.mounted) Navigator.pop(context, true); + + if (saved && context.mounted) { + Navigator.pop(context, true); + } }, style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), child: const Text('Delete'), diff --git a/app/lib/widgets/bookmarks/bookmark_list_item.dart b/app/lib/widgets/bookmarks/bookmark_list_item.dart index 7f95a9cb..e1f3eca2 100644 --- a/app/lib/widgets/bookmarks/bookmark_list_item.dart +++ b/app/lib/widgets/bookmarks/bookmark_list_item.dart @@ -160,25 +160,38 @@ class _BookmarkListItemState extends State { final now = DateTime.now(); final diff = now.difference(date); - if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 1) { + return 'Just now'; + } + if (diff.inMinutes < 60) { final m = diff.inMinutes; return '$m ${m == 1 ? 'minute' : 'minutes'} ago'; } + if (diff.inHours < 24) { final h = diff.inHours; return '$h ${h == 1 ? 'hour' : 'hours'} ago'; } - if (diff.inDays == 1) return 'Yesterday'; - if (diff.inDays < 7) return '${diff.inDays} days ago'; + + if (diff.inDays == 1) { + return 'Yesterday'; + } + + if (diff.inDays < 7) { + return '${diff.inDays} days ago'; + } + if (diff.inDays < 30) { final w = diff.inDays ~/ 7; return '$w ${w == 1 ? 'week' : 'weeks'} ago'; } + if (diff.inDays < 365) { final m = diff.inDays ~/ 30; return '$m ${m == 1 ? 'month' : 'months'} ago'; } + final y = diff.inDays ~/ 365; return '$y ${y == 1 ? 'year' : 'years'} ago'; } diff --git a/app/lib/widgets/buttons/google_sign_in.dart b/app/lib/widgets/buttons/google_sign_in.dart index ccbbc1e7..26fbeacb 100644 --- a/app/lib/widgets/buttons/google_sign_in.dart +++ b/app/lib/widgets/buttons/google_sign_in.dart @@ -27,22 +27,26 @@ class _GoogleSignInButtonState extends State { bool _isSigningIn = false; Future _handleSignIn() async { - if (_isSigningIn) return; + if (_isSigningIn) { + return; + } setState(() => _isSigningIn = true); try { final provider = Provider.of(context, listen: false); - final result = await provider.signInWithGoogle(); - if (!mounted) return; + if (!mounted) { + return; + } if (result) { widget.onSuccess?.call(); context.goNamed('LIBRARY'); } else if (provider.error != null) { widget.onError?.call(); + ScaffoldMessenger.of(context).showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( @@ -53,9 +57,12 @@ class _GoogleSignInButtonState extends State { ); } } catch (error) { - if (!mounted) return; + if (!mounted) { + return; + } widget.onError?.call(); + ScaffoldMessenger.of(context).showSnackBar( snackBarAnimationStyle: AppMotion.animationStyle(context), SnackBar( diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index c8d7735b..517ac469 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -101,6 +101,7 @@ class _BookContextBottomSheet extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return AppBottomSheet( header: Row( children: [ @@ -235,8 +236,12 @@ class _BookContextBottomSheet extends StatelessWidget { onTap: () { Navigator.pop(context); // Use a post-frame callback to ensure context is valid + WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; + if (!context.mounted) { + return; + } + BookContextMenu._confirmDelete(context, book, onDelete); }); }, @@ -250,10 +255,12 @@ class _BookContextBottomSheet extends StatelessWidget { Widget _buildCover(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final placeholder = Container( color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant), ); + return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } } diff --git a/app/lib/widgets/dashboard/continue_reading_card.dart b/app/lib/widgets/dashboard/continue_reading_card.dart index d2f6b9f8..895400f8 100644 --- a/app/lib/widgets/dashboard/continue_reading_card.dart +++ b/app/lib/widgets/dashboard/continue_reading_card.dart @@ -21,15 +21,17 @@ class ContinueReadingCard extends StatelessWidget { @override Widget build(BuildContext context) { - if (book == null) return _buildEmptyState(context); - if (isDesktop) return _buildDesktopCard(context); + if (book == null) { + return _buildEmptyState(context); + } + + if (isDesktop) { + return _buildDesktopCard(context); + } + return _buildMobileCard(context); } - // ============================================================================ - // MOBILE LAYOUT - // ============================================================================ - /// Builds the compact mobile card with cover, info, and play button. Widget _buildMobileCard(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -76,10 +78,6 @@ class ContinueReadingCard extends StatelessWidget { ); } - // ============================================================================ - // DESKTOP LAYOUT - // ============================================================================ - /// Builds the larger desktop card with cover, info, and continue button. Widget _buildDesktopCard(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -131,13 +129,10 @@ class ContinueReadingCard extends StatelessWidget { ); } - // ============================================================================ - // EMPTY STATE - // ============================================================================ - /// Builds the empty state when no book is currently being read. Widget _buildEmptyState(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, @@ -153,10 +148,6 @@ class ContinueReadingCard extends StatelessWidget { ); } - // ============================================================================ - // SHARED WIDGETS - // ============================================================================ - /// Builds the book cover image with rounded corners. Widget _buildCover(BuildContext context, {required double width, required double height}) { final colorScheme = Theme.of(context).colorScheme; @@ -181,7 +172,6 @@ class ContinueReadingCard extends StatelessWidget { /// Builds a placeholder icon when no cover image is available. Widget _buildCoverPlaceholder(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Center(child: Icon(Icons.menu_book, size: 32, color: colorScheme.onSurfaceVariant)); } diff --git a/app/lib/widgets/dashboard/dashboard_greeting.dart b/app/lib/widgets/dashboard/dashboard_greeting.dart index 52221a1f..5bcc4a3f 100644 --- a/app/lib/widgets/dashboard/dashboard_greeting.dart +++ b/app/lib/widgets/dashboard/dashboard_greeting.dart @@ -25,7 +25,6 @@ class DashboardGreeting extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final greetingStyle = isDesktop ? textTheme.headlineMedium : textTheme.titleLarge; return Padding( diff --git a/app/lib/widgets/dashboard/reading_goal_card.dart b/app/lib/widgets/dashboard/reading_goal_card.dart index e288d165..f0fb9e14 100644 --- a/app/lib/widgets/dashboard/reading_goal_card.dart +++ b/app/lib/widgets/dashboard/reading_goal_card.dart @@ -21,14 +21,13 @@ class ReadingGoalCard extends StatelessWidget { @override Widget build(BuildContext context) { - if (goals.isEmpty) return _buildEmptyState(context); + if (goals.isEmpty) { + return _buildEmptyState(context); + } + return _buildCard(context); } - // ============================================================================ - // CARD WITH GOALS - // ============================================================================ - /// Builds the card showing up to 3 active reading goals. Widget _buildCard(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -111,13 +110,10 @@ class ReadingGoalCard extends StatelessWidget { ); } - // ============================================================================ - // EMPTY STATE - // ============================================================================ - /// Builds the empty state when no reading goals are set. Widget _buildEmptyState(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + return Container( decoration: BoxDecoration( color: colorScheme.surfaceContainerLow, diff --git a/app/lib/widgets/dashboard/recently_added_section.dart b/app/lib/widgets/dashboard/recently_added_section.dart index b1ad4e98..fc554971 100644 --- a/app/lib/widgets/dashboard/recently_added_section.dart +++ b/app/lib/widgets/dashboard/recently_added_section.dart @@ -23,14 +23,13 @@ class RecentlyAddedSection extends StatelessWidget { @override Widget build(BuildContext context) { - if (books.isEmpty) return _buildEmptyState(context); + if (books.isEmpty) { + return _buildEmptyState(context); + } + return _buildSection(context); } - // ============================================================================ - // STANDARD LAYOUT - // ============================================================================ - /// Builds the section with header and horizontal book scroll. Widget _buildSection(BuildContext context) { final textTheme = Theme.of(context).textTheme; @@ -138,19 +137,11 @@ class RecentlyAddedSection extends StatelessWidget { ); } - // ============================================================================ - // EMPTY STATE - // ============================================================================ - /// Builds the empty state when no books have been added recently. Widget _buildEmptyState(BuildContext context) { return const EmptyState.compact(icon: Icons.library_books_outlined, title: 'No books added recently'); } - // ============================================================================ - // HELPERS - // ============================================================================ - void _onBookTap(BuildContext context, Book book) { if (onBookTap != null) { onBookTap!(book); diff --git a/app/lib/widgets/dashboard/weekly_activity_chart.dart b/app/lib/widgets/dashboard/weekly_activity_chart.dart index 507db300..7405c997 100644 --- a/app/lib/widgets/dashboard/weekly_activity_chart.dart +++ b/app/lib/widgets/dashboard/weekly_activity_chart.dart @@ -46,10 +46,6 @@ class WeeklyActivityChart extends StatelessWidget { return _buildChart(context); } - // ============================================================================ - // CHART LAYOUT - // ============================================================================ - /// Builds the chart container with header, bar chart, and optional summary. Widget _buildChart(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -133,7 +129,6 @@ class WeeklyActivityChart extends StatelessWidget { Widget _buildBar(BuildContext context, {required DailyActivity activity, required int maxMinutes}) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final maxHeight = 80.0; final barHeight = maxMinutes > 0 ? (activity.readingMinutes / maxMinutes * maxHeight).clamp(2.0, maxHeight) : 2.0; diff --git a/app/lib/widgets/goals/add_goal_sheet.dart b/app/lib/widgets/goals/add_goal_sheet.dart index da1c5f6a..4e56b5e7 100644 --- a/app/lib/widgets/goals/add_goal_sheet.dart +++ b/app/lib/widgets/goals/add_goal_sheet.dart @@ -23,11 +23,13 @@ class AddGoalSheet extends StatefulWidget { this.initialTimezone, this.initialMetric, }); + final GoalsProvider provider; final ReadingGoal? editing; final int? preset; final String? initialTimezone; final GoalType? initialMetric; + static Future show( BuildContext context, { required GoalsProvider provider, @@ -36,8 +38,10 @@ class AddGoalSheet extends StatefulWidget { String? initialTimezone, }) async { GoalType? metric; + if (editing == null && preset == null) { ModalBottomSheetRoute? chooserRoute; + metric = await showModalBottomSheet( context: context, useRootNavigator: true, @@ -51,10 +55,18 @@ class AddGoalSheet extends StatefulWidget { }, ); // Wait for the chooser to leave before presenting the differently sized form. + await chooserRoute?.completed; - if (metric == null || !context.mounted) return; + + if (metric == null || !context.mounted) { + return; + } } - if (!context.mounted) return; + + if (!context.mounted) { + return; + } + await showModalBottomSheet( context: context, useRootNavigator: true, @@ -93,21 +105,26 @@ class _AddGoalSheetState extends State { 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; @@ -174,9 +191,11 @@ class _AddGoalSheetState extends State { 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', @@ -206,7 +225,10 @@ class _AddGoalSheetState extends State { ? null : (value) => setState(() { _type = value!; - if (_type == GoalType.books && _bookIds.isNotEmpty) _target.text = '${_bookIds.length}'; + + if (_type == GoalType.books && _bookIds.isNotEmpty) { + _target.text = '${_bookIds.length}'; + } }), ), const SizedBox(height: Spacing.md), @@ -220,13 +242,18 @@ class _AddGoalSheetState extends State { decoration: InputDecoration(labelText: 'Target', suffixText: _typeLabel), validator: (value) { final error = _positive(value); - if (error != null) return error; + + 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; }, ), @@ -253,7 +280,10 @@ class _AddGoalSheetState extends State { ? null : (_) => setState(() { _period = period; - if (period == GoalPeriod.custom) _recurring = false; + + if (period == GoalPeriod.custom) { + _recurring = false; + } }), ), ], @@ -273,7 +303,10 @@ class _AddGoalSheetState extends State { firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 3650)), ); - if (date != null && mounted) setState(() => _deadline = date); + + if (date != null && mounted) { + setState(() => _deadline = date); + } }, icon: const Icon(Icons.event_outlined), label: Text('Deadline: ${_deadline.day}/${_deadline.month}/${_deadline.year}'), @@ -322,7 +355,10 @@ class _AddGoalSheetState extends State { onChanged: (value) => setState(() { _bookIds = value; _scopeId = value.firstOrNull; - if (_type == GoalType.books && value.isNotEmpty) _target.text = '${value.length}'; + + if (_type == GoalType.books && value.isNotEmpty) { + _target.text = '${value.length}'; + } }), ), ], @@ -361,11 +397,11 @@ class _AddGoalSheetState extends State { footer: BottomSheetFormActions( onCancel: _saving ? null : () => Navigator.pop(context), onSave: _saving || !_timezoneReady ? null : _save, - saveLabel: _saving - ? 'Saving…' - : editing - ? (_replacementNeeded ? 'Replace goal' : 'Save') - : 'Create goal', + saveLabel: switch (_saving) { + true => 'Saving…', + false when editing => (_replacementNeeded ? 'Replace goal' : 'Save'), + false => 'Create goal', + }, ), ), ); @@ -404,14 +440,20 @@ class _AddGoalSheetState extends State { bool get _replacementNeeded { final goal = widget.editing; - if (goal == null) return false; + + 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 || @@ -425,11 +467,15 @@ class _AddGoalSheetState extends State { } Future _save() async { - if (!_form.currentState!.validate()) return; + if (!_form.currentState!.validate()) { + return; + } + setState(() { _saving = true; _error = null; }); + try { if (widget.editing != null && !_replacementNeeded) { await widget.provider.updateGoal( @@ -455,7 +501,10 @@ class _AddGoalSheetState extends State { repository: _repository, ); } - if (mounted) Navigator.pop(context); + + if (mounted) { + Navigator.pop(context); + } } catch (error) { if (mounted) { setState(() { @@ -480,6 +529,7 @@ class _GoalMetricSheet extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + return GoalControls( child: AppBottomSheet( title: 'New goal', diff --git a/app/lib/widgets/goals/goal_card.dart b/app/lib/widgets/goals/goal_card.dart index f7020b72..b6dd17bf 100644 --- a/app/lib/widgets/goals/goal_card.dart +++ b/app/lib/widgets/goals/goal_card.dart @@ -12,10 +12,17 @@ IconData goalIcon(GoalType type) => switch (type) { GoalType.days => Icons.calendar_today_outlined, }; String goalTime(int seconds) { - if (seconds == 0) return '0 sec'; + if (seconds == 0) { + return '0 sec'; + } + final minutes = seconds ~/ 60; final remainder = seconds % 60; - if (minutes == 0) return '$remainder sec'; + + if (minutes == 0) { + return '$remainder sec'; + } + return remainder == 0 ? formatDuration(minutes) : '${formatDuration(minutes)} ${remainder}s'; } @@ -23,11 +30,12 @@ String goalCount(GoalProgress progress) => progress.goal.type == GoalType.minute ? '${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; +String goalRemaining(GoalProgress progress) => switch (progress.reached) { + true => 'Target reached', + false when progress.goal.type == GoalType.minutes => + '${goalTime((progress.goal.targetValue * 60 - progress.seconds).clamp(0, progress.goal.targetValue * 60))} to go', + false => progress.projected.statusText, +}; class GoalCard extends StatelessWidget { const GoalCard({ @@ -41,6 +49,7 @@ class GoalCard extends StatelessWidget { this.fillHeight = false, this.showReadingDays = false, }); + final ReadingGoal goal; final GoalProgress? progress; final VoidCallback? onTap; @@ -49,6 +58,7 @@ class GoalCard extends StatelessWidget { final bool isDesktop; final bool fillHeight; final bool showReadingDays; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -56,17 +66,16 @@ class GoalCard extends StatelessWidget { 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; + + final state = switch (projected) { + _ when projected.isArchived => 'Archived', + _ when !projected.isActive => 'Paused', + _ when expired && projected.isCompleted => 'Achieved', + _ when expired => 'Missed', + _ when projected.isCompleted => 'Target reached', + _ => null, + }; + return Card( semanticContainer: false, margin: EdgeInsets.zero, @@ -159,11 +168,11 @@ class GoalCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon( - projected.isCompleted - ? Icons.check_circle_outline - : goal.isActive - ? Icons.event_outlined - : Icons.pause_circle_outline, + switch (projected.isCompleted) { + true => Icons.check_circle_outline, + false when goal.isActive => Icons.event_outlined, + false => Icons.pause_circle_outline, + }, size: 16, ), const SizedBox(width: Spacing.xs), @@ -216,6 +225,7 @@ class GoalCard extends StatelessWidget { 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'}', diff --git a/app/lib/widgets/goals/goal_controls.dart b/app/lib/widgets/goals/goal_controls.dart index 722dd6b4..eca9a850 100644 --- a/app/lib/widgets/goals/goal_controls.dart +++ b/app/lib/widgets/goals/goal_controls.dart @@ -5,13 +5,16 @@ import 'package:papyrus/themes/design_tokens.dart'; 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)), diff --git a/app/lib/widgets/goals/goal_details_sheet.dart b/app/lib/widgets/goals/goal_details_sheet.dart index fa4f34a6..fad453f1 100644 --- a/app/lib/widgets/goals/goal_details_sheet.dart +++ b/app/lib/widgets/goals/goal_details_sheet.dart @@ -19,6 +19,7 @@ class GoalDetailsSheet extends StatefulWidget { final ReadingGoal goal; final GoalsProvider provider; final GoalProgress? historical; + static Future show( BuildContext context, { required ReadingGoal goal, @@ -33,6 +34,7 @@ class GoalDetailsSheet extends StatefulWidget { sheetAnimationStyle: AppMotion.animationStyle(context), builder: (_) => GoalDetailsSheet(goal: goal, provider: provider, historical: historical), ); + @override State createState() => _GoalDetailsSheetState(); } @@ -41,12 +43,14 @@ 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( @@ -55,8 +59,10 @@ class _GoalDetailsSheetState extends State { 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', @@ -169,18 +175,20 @@ class _GoalDetailsSheetState extends State { ); }, ); + Widget _progressSummary(BuildContext context, GoalProgress progress) { final goal = progress.projected; final texts = Theme.of(context).textTheme; final colors = Theme.of(context).colorScheme; final ended = !progress.range.end.isAfter(widget.provider.now); - final status = goal.isArchived - ? 'Archived' - : !goal.isActive - ? 'Paused' - : ended - ? (progress.reached ? 'Achieved' : 'Missed') - : goalRemaining(progress); + + final status = switch (goal.isArchived) { + true => 'Archived', + false when !goal.isActive => 'Paused', + false when ended => (progress.reached ? 'Achieved' : 'Missed'), + false => goalRemaining(progress), + }; + final scope = switch (goal.scope) { GoalScope.library => 'Whole library', GoalScope.book => @@ -189,8 +197,10 @@ class _GoalDetailsSheetState extends State { : 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: [ @@ -215,13 +225,12 @@ class _GoalDetailsSheetState extends State { 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, + switch (goal.isArchived) { + true => Icons.archive_outlined, + false when !goal.isActive => Icons.pause_circle_outline, + false when progress.reached => Icons.check_circle_outline, + false => Icons.event_outlined, + }, size: IconSizes.small, ), const SizedBox(width: Spacing.xs), @@ -271,6 +280,7 @@ class _GoalDetailsSheetState extends State { 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, @@ -290,20 +300,30 @@ class _GoalDetailsSheetState extends State { _saving = true; _error = null; }); + try { await action(); } catch (error) { _error = error.toString(); } finally { - if (mounted) setState(() => _saving = false); + if (mounted) { + setState(() => _saving = false); + } } } Future _delete() async { final confirmed = await confirmGoalDeletion(context); - if (confirmed != true || !mounted) return; + + if (confirmed != true || !mounted) { + return; + } + await _action(() => widget.provider.deleteGoal(widget.goal.id, repository: _repository)); - if (mounted && _error == null) Navigator.pop(context); + + if (mounted && _error == null) { + Navigator.pop(context); + } } } diff --git a/app/lib/widgets/goals/goal_timezone_picker.dart b/app/lib/widgets/goals/goal_timezone_picker.dart index 58fcb4e6..2fb1ee55 100644 --- a/app/lib/widgets/goals/goal_timezone_picker.dart +++ b/app/lib/widgets/goals/goal_timezone_picker.dart @@ -11,6 +11,7 @@ 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, @@ -20,6 +21,7 @@ class GoalTimezonePicker extends StatefulWidget { constraints: BoxConstraints(maxWidth: 640, maxHeight: MediaQuery.sizeOf(context).height * .85), builder: (_) => GoalTimezonePicker(selected: selected), ); + @override State createState() => _GoalTimezonePickerState(); } @@ -27,18 +29,35 @@ class GoalTimezonePicker extends StatefulWidget { 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); + ..sort((left, right) { + if (left == right) { + return 0; + } + + if (left == widget.selected) { + return -1; + } + + if (right == widget.selected) { + return 1; + } + + if (left == 'UTC') { + return -1; + } + + if (right == 'UTC') { + return 1; + } + + return left.compareTo(right); }); } @@ -52,6 +71,7 @@ class _GoalTimezonePickerState extends State { 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', @@ -76,6 +96,7 @@ class _GoalTimezonePickerState extends State { itemCount: zones.length, itemBuilder: (context, index) { final name = zones[index]; + return ListTile( title: Text(timezoneCity(name)), subtitle: Text(name), diff --git a/app/lib/widgets/goals/log_reading_sheet.dart b/app/lib/widgets/goals/log_reading_sheet.dart index 32332ab3..dfd84ac7 100644 --- a/app/lib/widgets/goals/log_reading_sheet.dart +++ b/app/lib/widgets/goals/log_reading_sheet.dart @@ -18,6 +18,7 @@ class LogReadingSheet extends StatefulWidget { final GoalsProvider provider; final Book? book; final ReadingActivity? correcting; + static Future show( BuildContext context, { required GoalsProvider provider, @@ -32,6 +33,7 @@ class LogReadingSheet extends StatefulWidget { constraints: const BoxConstraints(maxWidth: 640), builder: (_) => LogReadingSheet(provider: provider, book: book, correcting: correcting), ); + @override State createState() => _LogReadingSheetState(); } @@ -47,6 +49,7 @@ class _LogReadingSheetState extends State { bool _finished = false; bool _saving = false; String? _error; + @override void initState() { super.initState(); @@ -56,6 +59,7 @@ class _LogReadingSheetState extends State { _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'; @@ -71,7 +75,10 @@ class _LogReadingSheetState extends State { } String? _nonNegative(String? value) { - if (value == null || value.isEmpty) return null; + 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; } @@ -79,6 +86,7 @@ class _LogReadingSheetState extends State { @override Widget build(BuildContext context) { final books = widget.provider.store.books; + return GoalControls( child: AppBottomSheet( title: widget.correcting == null ? 'Log reading' : 'Correct reading entry', @@ -124,6 +132,7 @@ class _LogReadingSheetState extends State { firstDate: DateTime(1900), lastDate: DateTime.now(), ); + if (date != null && mounted) { setState( () => _end = DateTime(date.year, date.month, date.day, _end.hour, _end.minute), @@ -145,6 +154,7 @@ class _LogReadingSheetState extends State { 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), @@ -161,9 +171,11 @@ class _LogReadingSheetState extends State { 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, @@ -235,16 +247,22 @@ class _LogReadingSheetState extends State { ); Future _save() async { - if (!_form.currentState!.validate()) return; + 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, @@ -256,7 +274,10 @@ class _LogReadingSheetState extends State { correcting: widget.correcting, repository: _repository, ); - if (mounted) Navigator.pop(context); + + if (mounted) { + Navigator.pop(context); + } } catch (error) { if (mounted) { setState(() { @@ -319,18 +340,22 @@ class ReadingActivityList extends StatelessWidget { 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 entries = [...activities]..sort((left, right) => right.endTime.compareTo(left.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: [ @@ -381,31 +406,33 @@ class ReadingActivityTile extends StatelessWidget { 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(' · '); + final label = switch (activity.kind == 'completion') { + true => 'Finished book', + false when activity.kind == 'reversal' => 'Correction', + false => [ + 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, + switch (activity.kind == 'completion') { + true => Icons.check_circle_outline, + false when activity.source == 'reader' => Icons.auto_stories_outlined, + false => Icons.edit_outlined, + }, ), dense: !showBookTitle, title: Text( @@ -415,13 +442,13 @@ class ReadingActivityTile extends StatelessWidget { 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!), + subtitle: switch (showBookTitle) { + true => Text( + '$label · ${activity.source == 'reader' ? 'Reader' : 'Manual'}${corrected ? ' · Corrected' : ''}\n${DateFormat.yMMMd().add_Hm().format(activity.startTime.toLocal())}', + ), + false when contributionLabel == null => null, + false => Text(contributionLabel!), + }, trailing: showBookTitle ? null : Text(DateFormat.Hm().format(activity.startTime.toLocal()), style: Theme.of(context).textTheme.bodySmall), @@ -446,6 +473,7 @@ class _ActivityDetails extends StatefulWidget { final GoalsProvider provider; final ReadingActivity activity; final bool corrected; + @override State<_ActivityDetails> createState() => _ActivityDetailsState(); } @@ -454,10 +482,12 @@ 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', @@ -537,7 +567,10 @@ class _ActivityDetailsState extends State<_ActivityDetails> { } String _duration(int seconds) { - if (seconds < 60) return '$seconds sec'; + if (seconds < 60) { + return '$seconds sec'; + } + final hours = seconds ~/ 3600; final minutes = (seconds % 3600) ~/ 60; final remainder = seconds % 60; @@ -550,6 +583,7 @@ class _ActivityDetailsState extends State<_ActivityDetails> { 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(' · '); } @@ -577,11 +611,19 @@ class _ActivityDetailsState extends State<_ActivityDetails> { ], ), ); - if (confirmed != true || !mounted) return; + + if (confirmed != true || !mounted) { + return; + } + setState(() => _saving = true); + try { await widget.provider.reverseActivity(widget.activity, repository: _repository); - if (mounted) Navigator.pop(context); + + if (mounted) { + Navigator.pop(context); + } } catch (error) { if (mounted) { setState(() { diff --git a/app/lib/widgets/goals/reading_activity_heatmap.dart b/app/lib/widgets/goals/reading_activity_heatmap.dart index 99545ff4..ed626d47 100644 --- a/app/lib/widgets/goals/reading_activity_heatmap.dart +++ b/app/lib/widgets/goals/reading_activity_heatmap.dart @@ -18,6 +18,7 @@ class ReadingActivityHeatmap extends StatefulWidget { required this.onDaySelected, this.selectedDay, }); + final List activities; final int year; final String timezone; @@ -25,6 +26,7 @@ class ReadingActivityHeatmap extends StatefulWidget { final ValueChanged onYearChanged; final ValueChanged onDaySelected; final DateTime? selectedDay; + @override State createState() => _ReadingActivityHeatmapState(); } @@ -33,10 +35,14 @@ 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; + + if (oldWidget.year != widget.year || oldWidget.timezone != widget.timezone) { + _positionPending = true; + } } @override @@ -55,22 +61,26 @@ class _ReadingActivityHeatmapState extends State { 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)); + }.where((year) => year <= currentYear).toList()..sort((left, right) => right.compareTo(left)); + return Container( key: const Key('reading-activity-heatmap'), padding: const EdgeInsets.all(Spacing.lg), @@ -102,7 +112,9 @@ class _ReadingActivityHeatmapState extends State { 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); + if (year != null) { + widget.onYearChanged(year); + } }, ), ), @@ -114,31 +126,44 @@ class _ReadingActivityHeatmapState extends State { 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; + 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, @@ -147,7 +172,9 @@ class _ReadingActivityHeatmapState extends State { ); }); } + final headingHeight = MediaQuery.textScalerOf(context).scale(20); + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -274,7 +301,11 @@ class _ReadingActivityHeatmapState extends State { 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; + + if (date.year != widget.year || (date.day > 7 && week != 0)) { + return null; + } + return Text(DateFormat.MMM().format(date), style: texts.labelSmall); } @@ -290,15 +321,19 @@ class _ReadingActivityHeatmapState extends State { 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 duration = switch (seconds == 0) { + true => switch (value?.hasActivity == true) { + true => 'Reading logged', + false => 'No reading', + }, + false when seconds < 60 => '$seconds sec', + false => '${seconds ~/ 60} min', + }; + final label = '${DateFormat.yMMMd().format(GoalCalendar.local(day, widget.timezone))}: ${future ? 'Future date' : duration}'; + return SizedBox( width: size, height: size, diff --git a/app/lib/widgets/goals/reading_activity_timeline.dart b/app/lib/widgets/goals/reading_activity_timeline.dart index b2cf6606..e700035f 100644 --- a/app/lib/widgets/goals/reading_activity_timeline.dart +++ b/app/lib/widgets/goals/reading_activity_timeline.dart @@ -19,6 +19,7 @@ class ReadingActivityTimeline extends StatelessWidget { this.correctedIds = const {}, this.range, }); + final List activities; final GoalsProvider provider; final String timezone; @@ -28,15 +29,22 @@ class ReadingActivityTimeline extends StatelessWidget { @override Widget build(BuildContext context) { final days = >>{}; - final sorted = [...activities]..sort((a, b) => b.endTime.compareTo(a.endTime)); + final sorted = [...activities]..sort((left, right) => right.endTime.compareTo(left.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; + + 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); @@ -44,15 +52,18 @@ class ReadingActivityTimeline extends StatelessWidget { } 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)))) ...[ + for (final day in (days.entries.toList()..sort((left, right) => right.key.compareTo(left.key)))) ...[ Padding( padding: const EdgeInsets.only(top: Spacing.lg, bottom: Spacing.sm), child: Semantics( @@ -90,6 +101,7 @@ class ReadingActivityTimeline extends StatelessWidget { 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', @@ -106,7 +118,9 @@ class ReadingActivityTimeline extends StatelessWidget { 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), @@ -114,10 +128,12 @@ class ReadingActivityTimeline extends StatelessWidget { ...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()}'), diff --git a/app/lib/widgets/input/search_field.dart b/app/lib/widgets/input/search_field.dart index fb8b3526..b8b28d14 100644 --- a/app/lib/widgets/input/search_field.dart +++ b/app/lib/widgets/input/search_field.dart @@ -23,6 +23,7 @@ class SearchField extends StatelessWidget { /// Called when the text changes. final ValueChanged? onChanged; + final ValueChanged? onSubmitted; /// Called when the clear button is pressed. @@ -54,6 +55,7 @@ class SearchField extends StatelessWidget { void _handleClear() { controller.clear(); + if (onClear != null) { onClear!(); } else { diff --git a/app/lib/widgets/library/acquisition_job_sheets.dart b/app/lib/widgets/library/acquisition_job_sheets.dart index 79ed8780..8e7a64f5 100644 --- a/app/lib/widgets/library/acquisition_job_sheets.dart +++ b/app/lib/widgets/library/acquisition_job_sheets.dart @@ -170,6 +170,7 @@ class _AcquisitionJobDetailsContent extends StatelessWidget { @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; + return AppBottomSheet( header: Semantics( key: const Key('acquisition-job-details-title'), @@ -325,6 +326,5 @@ class _SelectFile extends _AcquisitionJobAction { String _fileName(String path) { final segments = path.split(RegExp(r'[/\\]')); - return segments.lastWhere((segment) => segment.isNotEmpty, orElse: () => path); } diff --git a/app/lib/widgets/library/book_account_status_badge.dart b/app/lib/widgets/library/book_account_status_badge.dart index d4152472..5b597529 100644 --- a/app/lib/widgets/library/book_account_status_badge.dart +++ b/app/lib/widgets/library/book_account_status_badge.dart @@ -10,6 +10,7 @@ class BookAccountStatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final (label, icon, foreground, background) = switch (status) { BookAccountStatus.saved => ( 'Saved', diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 5578ec64..f2005e9c 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -76,6 +76,7 @@ class _BookCardState extends State { final inSelection = widget.isSelectionMode; final effectiveTap = inSelection ? widget.onSelectToggle : widget.onTap; final effectiveLongPress = inSelection ? null : widget.onEnterSelectionMode; + final isUnavailable = widget.acquisitionJob == null && !widget.book.isPhysical && widget.deviceStatus == BookDeviceStatus.missing; @@ -333,9 +334,11 @@ class _BookCardState extends State { mediaId: widget.book.coverMediaId, placeholder: _buildPlaceholder(context), ); + if (widget.book.isPhysical || widget.deviceStatus != BookDeviceStatus.missing) { return cover; } + return ColorFiltered( key: ValueKey('book-unavailable-tint-${widget.book.id}'), colorFilter: const ColorFilter.matrix([ @@ -366,6 +369,7 @@ class _BookCardState extends State { Widget _buildPlaceholder(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + if (widget.compact) { return ColoredBox( color: colorScheme.surfaceContainerHighest, diff --git a/app/lib/widgets/library/book_grid.dart b/app/lib/widgets/library/book_grid.dart index f2c06e98..df507752 100644 --- a/app/lib/widgets/library/book_grid.dart +++ b/app/lib/widgets/library/book_grid.dart @@ -57,6 +57,7 @@ class BookGrid extends StatelessWidget { final textScaler = MediaQuery.textScalerOf(context); // Reserve space for scaled metadata and the two-line cover placeholder. // Otherwise the smallest fitted covers can overflow with larger text. + final additionalTextHeight = [ (style: textTheme.titleSmall, lines: 2), @@ -67,6 +68,7 @@ class BookGrid extends StatelessWidget { final increase = (textScaler.scale(fontSize) - fontSize).clamp(0.0, double.infinity); return height + increase * (entry.style?.height ?? 1) * entry.lines; }); + final storageStatusController = context.watch(); final bookIds = books.map((book) => book.id).toSet(); final placeholderJobsByBookId = {}; @@ -111,23 +113,28 @@ class BookGrid extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final gridPadding = padding ?? const EdgeInsets.only(left: Spacing.md, right: Spacing.md, bottom: Spacing.md); + final layout = _resolveGridLayout( width: constraints.maxWidth - gridPadding.horizontal, viewMode: libraryViewMode, itemWidth: libraryProvider.gridItemWidth, ); + final compact = layout.itemWidth < BookGridSize.regularMinimum; double lineHeight(TextStyle? style) => (style?.fontSize ?? 14) * (style?.height ?? 1); // Small covers still need room for titles, authors and download details. + final compactMetadataHeight = lineHeight(textTheme.titleSmall) * 2 + lineHeight(textTheme.bodySmall) + lineHeight(textTheme.labelSmall) + Spacing.xs * 2 + 7; + final itemHeight = compact ? layout.itemWidth * 1.5 + compactMetadataHeight + additionalTextHeight : layout.itemWidth / layout.childAspectRatio + additionalTextHeight; + return MediaQuery.removePadding( context: context, removeTop: true, @@ -167,6 +174,7 @@ class BookGrid extends StatelessWidget { final book = books[index]; final job = linkedJobsByBookId[book.id]; final isFavorite = libraryProvider.isBookFavorite(book.id, book.isFavorite); + if (job == null) { unawaited(storageStatusController?.ensureDeviceStatus(book)); } @@ -176,27 +184,26 @@ class BookGrid extends StatelessWidget { book: book, isFavorite: isFavorite, onToggleFavorite: job == null ? (current) => toggleBookFavorite(context, book.id, current) : null, - onTap: job != null - ? onAcquisitionTap == null - ? null - : () => onAcquisitionTap!(job) - : onBookTap == null - ? null - : () => onBookTap!(book), + onTap: switch (job) { + final job? when onAcquisitionTap != null => () => onAcquisitionTap!(job), + _? => null, + null when onBookTap != null => () => onBookTap!(book), + null => null, + }, isSelectionMode: job != null ? selectedAcquisitionJobIds.isNotEmpty : libraryProvider.isSelectionMode, isSelected: job != null ? selectedAcquisitionJobIds.contains(job.id) : libraryProvider.isBookSelected(book.id), - onSelectToggle: job != null - ? onAcquisitionSelectionToggle == null - ? null - : () => onAcquisitionSelectionToggle!(job) - : () => libraryProvider.toggleBookSelection(book.id), - onEnterSelectionMode: job != null - ? onAcquisitionSelectionToggle == null - ? null - : () => onAcquisitionSelectionToggle!(job) - : () => libraryProvider.enterSelectionMode(book.id), + onSelectToggle: switch (job) { + final job? when onAcquisitionSelectionToggle != null => () => onAcquisitionSelectionToggle!(job), + _? => null, + null => () => libraryProvider.toggleBookSelection(book.id), + }, + onEnterSelectionMode: switch (job) { + final job? when onAcquisitionSelectionToggle != null => () => onAcquisitionSelectionToggle!(job), + _? => null, + null => () => libraryProvider.enterSelectionMode(book.id), + }, acquisitionJob: job, accountStatus: job == null ? storageStatusController?.accountStatus(book) : null, deviceStatus: job == null ? storageStatusController?.deviceStatus(book) : null, diff --git a/app/lib/widgets/library/book_grid_layout.dart b/app/lib/widgets/library/book_grid_layout.dart index 6058bf3d..f351495f 100644 --- a/app/lib/widgets/library/book_grid_layout.dart +++ b/app/lib/widgets/library/book_grid_layout.dart @@ -9,9 +9,11 @@ typedef BookGridLayout = ({int crossAxisCount, double spacing, double childAspec BookGridLayout bookGridLayout(double width, {double itemWidth = BookGridSize.defaultWidth}) { final spacing = width >= Breakpoints.tablet ? Spacing.md : Spacing.sm; final compactArea = width < Breakpoints.tablet; + final preferredWidth = compactArea ? BookGridSize.normalize(itemWidth) : math.max(BookGridSize.regularMinimum, BookGridSize.normalize(itemWidth)); + final desiredColumns = math.max(1, ((width + spacing) / (preferredWidth + spacing)).floor()); final columns = compactArea ? math.min(4, desiredColumns) : desiredColumns; final fittedWidth = math.max(0.0, (width - spacing * (columns - 1)) / columns); @@ -24,12 +26,15 @@ typedef BookGridSizeOption = ({int columns, double preferredWidth}); /// Keep the stored width preference so density still adapts across screen sizes. List bookGridSizeOptions(double width) { final options = {}; + for (double size = BookGridSize.minimum; size <= BookGridSize.maximum; size += BookGridSize.step) { final columns = bookGridLayout(width, itemWidth: size).crossAxisCount; final previous = options[columns]; + if (previous == null || (size - BookGridSize.defaultWidth).abs() < (previous - BookGridSize.defaultWidth).abs()) { options[columns] = size; } } + return [for (final columns in options.keys.toList()..sort()) (columns: columns, preferredWidth: options[columns]!)]; } diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index 3a89a067..ada5afc7 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -65,34 +65,38 @@ class _BookListItemState extends State { final inSelection = isAcquisition ? widget.isAcquisitionSelectionMode : widget.isSelectionMode; final isSelected = isAcquisition ? widget.isAcquisitionSelected : widget.isSelected; final onSelectionToggle = isAcquisition ? widget.onAcquisitionSelectionToggle : widget.onSelectToggle; - final onTap = isAcquisition - ? inSelection - ? widget.onAcquisitionSelectionToggle - : widget.onAcquisitionTap - : inSelection - ? widget.onSelectToggle - : widget.onTap; + + final onTap = switch (isAcquisition) { + true => switch (inSelection) { + true => widget.onAcquisitionSelectionToggle, + false => widget.onAcquisitionTap, + }, + false when inSelection => widget.onSelectToggle, + false => widget.onTap, + }; + final isUnavailable = !isAcquisition && !widget.book.isPhysical && widget.deviceStatus == BookDeviceStatus.missing; return MouseRegion( onEnter: (_) => setState(() => _isHovered = true), onExit: (_) => setState(() => _isHovered = false), child: GestureDetector( - onLongPressStart: isAcquisition - ? widget.onAcquisitionSelectionToggle == null - ? null - : (_) => widget.onAcquisitionSelectionToggle!() - : _isDesktop - ? null - : (details) { - showBookContextMenu(context: context, book: widget.book, position: details.globalPosition); - }, + onLongPressStart: switch (isAcquisition) { + true => switch (widget.onAcquisitionSelectionToggle == null) { + true => null, + false => (_) => widget.onAcquisitionSelectionToggle!(), + }, + false when _isDesktop => null, + false => (details) { + showBookContextMenu(context: context, book: widget.book, position: details.globalPosition); + }, + }, child: Material( - color: inSelection && isSelected - ? colorScheme.primary.withValues(alpha: 0.08) - : isUnavailable - ? colorScheme.surfaceContainerLow - : Colors.transparent, + color: switch (inSelection && isSelected) { + true => colorScheme.primary.withValues(alpha: 0.08), + false when isUnavailable => colorScheme.surfaceContainerLow, + false => Colors.transparent, + }, child: InkWell( onTap: onTap, child: Container( @@ -272,9 +276,11 @@ class _BookListItemState extends State { mediaId: widget.book.coverMediaId, placeholder: _buildPlaceholder(context), ); + if (widget.book.isPhysical || widget.deviceStatus != BookDeviceStatus.missing) { return cover; } + return ColorFiltered( key: ValueKey('book-unavailable-tint-${widget.book.id}'), colorFilter: const ColorFilter.matrix([ diff --git a/app/lib/widgets/library/library_advanced_filter_sheet.dart b/app/lib/widgets/library/library_advanced_filter_sheet.dart index e33c502c..8219be7f 100644 --- a/app/lib/widgets/library/library_advanced_filter_sheet.dart +++ b/app/lib/widgets/library/library_advanced_filter_sheet.dart @@ -60,6 +60,7 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { ), ); } + return DraggableScrollableSheet( expand: false, initialChildSize: 0.85, @@ -97,6 +98,7 @@ class LibraryAdvancedFilterSheet extends StatefulWidget { class _LibraryAdvancedFilterSheetState extends State { late LibraryFilters _draft = widget.libraryProvider.filters; late final List _sourceBooks = widget.sourceBooks ?? widget.dataStore.books; + late final LibraryFilterOptions _options = widget.filterOptions ?? (widget.sourceBooks == null @@ -135,16 +137,22 @@ class _LibraryAdvancedFilterSheetState extends State String _authorLabel(String value) { for (final book in widget.dataStore.books) { for (final author in [book.author, ...book.coAuthors]) { - if (author.trim().toLowerCase() == value) return author.trim(); + if (author.trim().toLowerCase() == value) { + return author.trim(); + } } } + return 'Unknown author ($value)'; } String _languageLabel(String value) { for (final book in widget.dataStore.books) { - if (normalizeBookLanguage(book.language) == value) return bookLanguageLabel(book.language ?? value); + if (normalizeBookLanguage(book.language) == value) { + return bookLanguageLabel(book.language ?? value); + } } + final label = bookLanguageLabel(value); return label == value ? 'Unknown language ($value)' : label; } @@ -152,22 +160,32 @@ class _LibraryAdvancedFilterSheetState extends State String _bookFieldLabel(String value, String? Function(Book book) field, String fieldName) { for (final book in widget.dataStore.books) { final label = field(book)?.trim(); - if (label != null && label.toLowerCase() == value) return label; + + if (label != null && label.toLowerCase() == value) { + return label; + } } + return 'Unknown $fieldName ($value)'; } String _shelfLabel(String value) { for (final shelf in widget.dataStore.shelves) { - if (shelf.id == value) return shelf.name; + if (shelf.id == value) { + return shelf.name; + } } + return 'Unknown shelf ($value)'; } String _topicLabel(String value) { for (final topic in widget.dataStore.tags) { - if (topic.id == value) return topic.name; + if (topic.id == value) { + return topic.name; + } } + return 'Unknown topic ($value)'; } @@ -175,36 +193,44 @@ class _LibraryAdvancedFilterSheetState extends State Widget build(BuildContext context) { final authorOptions = _withSelectedOptions(_options.authors, _draft.authors, _authorLabel); final languageOptions = _withSelectedOptions(_options.languages, _draft.languages, _languageLabel); + final publisherOptions = _withSelectedOptions( _options.publishers, _draft.publishers, (value) => _bookFieldLabel(value, (book) => book.publisher, 'publisher'), ); + final formatOptions = _withSelectedOptions( _options.formats, _draft.formats, (value) => _bookFieldLabel(value, (book) => book.formatLabel, 'format'), ); + final seriesOptions = _withSelectedOptions( _options.series, _draft.seriesNames, (value) => _bookFieldLabel(value, (book) => book.seriesName, 'series'), ); + final shelfOptions = _withSelectedOptions(_options.shelves, _draft.shelfIds, _shelfLabel); final topicOptions = _withSelectedOptions(_options.topics, _draft.topicIds, _topicLabel); + final readingStatusOptions = _withSelectedOptions( _options.readingStatuses, _draft.statuses, (status) => status.label, ); + final availableRatings = {..._options.ratings, ..._draft.ratings}.toList()..sort(); final showUnrated = _options.hasUnrated || _draft.includeUnrated; + final hasMetadataOptions = authorOptions.isNotEmpty || languageOptions.isNotEmpty || formatOptions.isNotEmpty || publisherOptions.isNotEmpty || seriesOptions.isNotEmpty; + final hasOrganizationOptions = shelfOptions.isNotEmpty || topicOptions.isNotEmpty; final hasRatingOptions = availableRatings.isNotEmpty || showUnrated; @@ -329,6 +355,7 @@ class _LibraryAdvancedFilterSheetState extends State Widget _buildActionBar(BuildContext context) { final count = _matchingBookCount; + return BottomSheetFormActions( onCancel: _resetDraft, cancelLabel: 'Reset', @@ -348,6 +375,7 @@ class _SectionHeader extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -421,11 +449,13 @@ class _SearchableFacetState extends State<_SearchableFacet> { void _updateSelection(LibraryFilterOption option, bool isSelected) { final values = Set.of(widget.selectedValues); + if (isSelected) { values.add(option.value); } else { values.remove(option.value); } + widget.onChanged(values); } @@ -436,11 +466,14 @@ class _SearchableFacetState extends State<_SearchableFacet> { final inputDecorationTheme = theme.inputDecorationTheme; final isEink = isLibraryFilterEinkTheme(theme); final borderRadius = BorderRadius.circular(isEink ? AppRadius.none : AppRadius.lg); + final headerBorderRadius = _isExpanded ? BorderRadius.vertical(top: Radius.circular(isEink ? AppRadius.none : AppRadius.lg)) : borderRadius; + final selectionSummary = widget.selectedValues.isEmpty ? 'Any' : '${widget.selectedValues.length} selected'; final normalizedQuery = _query.trim().toLowerCase(); + final visibleOptions = normalizedQuery.isEmpty ? widget.options : widget.options.where((option) => option.label.toLowerCase().contains(normalizedQuery)).toList(); @@ -563,6 +596,7 @@ class _SearchableFacetState extends State<_SearchableFacet> { itemBuilder: (context, index) { final option = visibleOptions[index]; final isSelected = widget.selectedValues.contains(option.value); + return _FacetOptionRow( label: option.label, isSelected: isSelected, diff --git a/app/lib/widgets/library/library_filter_chips.dart b/app/lib/widgets/library/library_filter_chips.dart index d3755934..cab6170e 100644 --- a/app/lib/widgets/library/library_filter_chips.dart +++ b/app/lib/widgets/library/library_filter_chips.dart @@ -142,6 +142,7 @@ class _MultiSelectionSheetState extends State<_MultiSelectionSheet> { @override Widget build(BuildContext context) { final normalizedQuery = _searchQuery.trim().toLowerCase(); + final visibleOptions = normalizedQuery.isEmpty ? widget.options : widget.options.where((option) => option.label.toLowerCase().contains(normalizedQuery)).toList(); @@ -288,15 +289,18 @@ class LibraryFilterChips extends StatelessWidget { final provider = context.watch(); final dataStore = context.watch(); final filterOptions = this.filterOptions ?? LibraryFilterOptions.fromDataStore(dataStore); + final statusOptions = [ for (final option in filterOptions.readingStatuses) _SelectionOption(value: option.value, label: option.label, icon: option.value.icon), ]; + final effectiveStatusOptions = _withSelectedSelectionOptions( statusOptions, provider.selectedStatuses, (status) => status.label, ); + final authorOptions = _withIcon( _withSelectedFilterOptions( filterOptions.authors, @@ -305,6 +309,7 @@ class LibraryFilterChips extends StatelessWidget { ), Icons.person_outline_rounded, ); + final languageOptions = _withIcon( _withSelectedFilterOptions( filterOptions.languages, @@ -313,6 +318,7 @@ class LibraryFilterChips extends StatelessWidget { ), Icons.language_rounded, ); + final formatOptions = _withIcon( _withSelectedFilterOptions( filterOptions.formats, @@ -321,6 +327,7 @@ class LibraryFilterChips extends StatelessWidget { ), Icons.description_outlined, ); + final topicOptions = _withIcon( _withSelectedFilterOptions( filterOptions.topics, @@ -329,6 +336,7 @@ class LibraryFilterChips extends StatelessWidget { ), Icons.label_outline_rounded, ); + final shelfOptions = _withIcon( _withSelectedFilterOptions( filterOptions.shelves, @@ -337,6 +345,7 @@ class LibraryFilterChips extends StatelessWidget { ), Icons.folder_outlined, ); + final selectedSort = _sortOptions.firstWhere((option) => option.value == provider.sortOption); final selectedFavorite = _favoriteOptions.firstWhere((option) => option.value == provider.favoriteFilter); @@ -520,9 +529,9 @@ class LibraryFilterChips extends StatelessWidget { final hasActiveSelections = chips.any((chip) => chip.isActive) || provider.activeFilterCount > 0 || isDownloadingSelected; - chips.sort((a, b) { - final activeComparison = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); - return activeComparison != 0 ? activeComparison : a.defaultOrder.compareTo(b.defaultOrder); + chips.sort((left, right) { + final activeComparison = (right.isActive ? 1 : 0).compareTo(left.isActive ? 1 : 0); + return activeComparison != 0 ? activeComparison : left.defaultOrder.compareTo(right.defaultOrder); }); final orderKey = [...chips.map((chip) => chip.id), if (hasActiveSelections) 'clear-all'].join('-'); @@ -536,6 +545,7 @@ class LibraryFilterChips extends StatelessWidget { switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { final offsetAnimation = Tween(begin: const Offset(0.03, 0), end: Offset.zero).animate(animation); + return FadeTransition( opacity: animation, child: SlideTransition(position: offsetAnimation, child: child), @@ -562,9 +572,11 @@ class LibraryFilterChips extends StatelessWidget { key: const ValueKey('clear-all'), onPressed: () { provider.resetQuickFilters(); + if (isDownloadingSelected) { onDownloadingTapped?.call(); } + onLibraryFilterTapped?.call(); }, style: TextButton.styleFrom( @@ -632,16 +644,19 @@ class LibraryFilterChips extends StatelessWidget { if (selectedValues.isEmpty) { return category; } + if (selectedValues.length > 1) { return '$category · ${selectedValues.length}'; } final selectedValue = selectedValues.first; + for (final option in options) { if (option.value == selectedValue) { return option.label; } } + return category; } @@ -683,6 +698,7 @@ class LibraryFilterChips extends StatelessWidget { } } } + return 'Unknown author ($value)'; } @@ -692,6 +708,7 @@ class LibraryFilterChips extends StatelessWidget { return book.formatLabel; } } + return 'Unknown format ($value)'; } @@ -701,6 +718,7 @@ class LibraryFilterChips extends StatelessWidget { return bookLanguageLabel(book.language ?? value); } } + final label = bookLanguageLabel(value); return label == value ? 'Unknown language ($value)' : label; } @@ -711,6 +729,7 @@ class LibraryFilterChips extends StatelessWidget { return topic.name; } } + return 'Unknown topic ($value)'; } @@ -720,6 +739,7 @@ class LibraryFilterChips extends StatelessWidget { return shelf.name; } } + return 'Unknown shelf ($value)'; } } diff --git a/app/lib/widgets/library/library_filter_fields.dart b/app/lib/widgets/library/library_filter_fields.dart index 096b3de4..d0c1123d 100644 --- a/app/lib/widgets/library/library_filter_fields.dart +++ b/app/lib/widgets/library/library_filter_fields.dart @@ -7,6 +7,7 @@ import 'package:papyrus/widgets/shared/app_motion_control.dart'; bool isLibraryFilterEinkTheme(ThemeData theme) { final border = theme.inputDecorationTheme.border; + return border is OutlineInputBorder && border.borderRadius == BorderRadius.zero && border.borderSide.width >= BorderWidths.einkDefault; @@ -113,15 +114,18 @@ class LibrarySmallFacet extends StatelessWidget { Builder( builder: (context) { final isSelected = selectedValues.contains(option.value); + return _selectionChip( context, label: option.label, isSelected: isSelected, onSelected: () { final values = Set.of(selectedValues); + if (!values.add(option.value)) { values.remove(option.value); } + onChanged(values); }, ); @@ -295,15 +299,18 @@ class LibraryRatingFilterField extends StatelessWidget { Builder( builder: (context) { final isSelected = ratings.contains(rating); + return _selectionChip( context, label: List.filled(rating, '★').join(), isSelected: isSelected, onSelected: () { final values = Set.of(ratings); + if (!values.add(rating)) { values.remove(rating); } + onChanged(values, includeUnrated); }, ); @@ -324,6 +331,7 @@ class LibraryDateRangeField extends StatelessWidget { Future _pickRange(BuildContext context) async { final now = DateTime.now(); + final selectedRange = await showAppDateRangePicker( context: context, firstDate: DateTime(1000), @@ -343,10 +351,13 @@ class LibraryDateRangeField extends StatelessWidget { final isEink = isLibraryFilterEinkTheme(theme); final localizations = MaterialLocalizations.of(context); final value = this.value; + final summary = value == null ? 'Any' : '${localizations.formatCompactDate(value.start)} – ${localizations.formatCompactDate(value.end)}'; + final borderRadius = BorderRadius.circular(isEink ? AppRadius.none : AppRadius.sm); + final pickerBorderRadius = value == null ? borderRadius : BorderRadius.horizontal(left: Radius.circular(isEink ? AppRadius.none : AppRadius.sm)); diff --git a/app/lib/widgets/library/library_page_header.dart b/app/lib/widgets/library/library_page_header.dart index 0671732c..dc401662 100644 --- a/app/lib/widgets/library/library_page_header.dart +++ b/app/lib/widgets/library/library_page_header.dart @@ -63,13 +63,17 @@ class LibraryPageHeader extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: Spacing.md, vertical: Spacing.sm), child: LayoutBuilder( builder: (context, constraints) { - if (actions.isEmpty) return heading; + if (actions.isEmpty) { + return heading; + } + final actionGroup = Wrap( spacing: Spacing.xs, runSpacing: Spacing.sm, crossAxisAlignment: WrapCrossAlignment.center, children: actions, ); + if (constraints.maxWidth < Breakpoints.tablet || MediaQuery.textScalerOf(context).scale(1) > 1.4) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -80,6 +84,7 @@ class LibraryPageHeader extends StatelessWidget { ], ); } + return Row( children: [ Expanded(child: heading), @@ -108,6 +113,7 @@ class LibraryMobileToolbar extends StatelessWidget { @override Widget build(BuildContext context) { final menu = IconButton(icon: const Icon(Icons.menu), onPressed: onMenuPressed, tooltip: 'Library sections'); + return Row( children: [ if (searchBuilder != null) Expanded(child: searchBuilder!(menu)) else ...[menu, const Spacer()], @@ -126,14 +132,20 @@ class LibraryToolbar extends StatelessWidget { @override Widget build(BuildContext context) { - if (actions.isEmpty) return search ?? const SizedBox.shrink(); + if (actions.isEmpty) { + return search ?? const SizedBox.shrink(); + } + final actionGroup = Wrap( spacing: Spacing.sm, runSpacing: Spacing.sm, crossAxisAlignment: WrapCrossAlignment.center, children: actions, ); - if (search == null) return Align(alignment: Alignment.centerRight, child: actionGroup); + + if (search == null) { + return Align(alignment: Alignment.centerRight, child: actionGroup); + } return LayoutBuilder( builder: (context, constraints) { @@ -147,6 +159,7 @@ class LibraryToolbar extends StatelessWidget { ], ); } + return Row( children: [ Expanded(child: search!), diff --git a/app/lib/widgets/library/online_books_header.dart b/app/lib/widgets/library/online_books_header.dart index c53970ab..eff5be10 100644 --- a/app/lib/widgets/library/online_books_header.dart +++ b/app/lib/widgets/library/online_books_header.dart @@ -35,11 +35,13 @@ class OnlineBooksHeader extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final useCompactLayout = constraints.maxWidth < Breakpoints.tablet; + final backButton = Semantics( label: 'Back', button: true, child: IconButton(icon: const Icon(Icons.arrow_back), onPressed: onBack, tooltip: 'Back'), ); + final title = Text( 'Online results', style: textTheme.headlineSmall, diff --git a/app/lib/widgets/library/online_results_view.dart b/app/lib/widgets/library/online_results_view.dart index 665971f2..d584c03b 100644 --- a/app/lib/widgets/library/online_results_view.dart +++ b/app/lib/widgets/library/online_results_view.dart @@ -69,6 +69,7 @@ class OnlineResultsView extends StatelessWidget { if (releases.isEmpty) { final trimmedQuery = query.trim(); + final subtitle = trimmedQuery.isEmpty ? 'Try another title or author.' : 'No releases found for “$trimmedQuery”. Try another title or author.'; diff --git a/app/lib/widgets/library/remote_release_list.dart b/app/lib/widgets/library/remote_release_list.dart index fd6e3654..f628ceaf 100644 --- a/app/lib/widgets/library/remote_release_list.dart +++ b/app/lib/widgets/library/remote_release_list.dart @@ -30,16 +30,19 @@ class RemoteReleaseList extends StatelessWidget { final selected = selectedReleaseTokens.contains(release.releaseToken); final trimmedError = errorsByReleaseToken[release.releaseToken]?.trim(); final error = trimmedError == null || trimmedError.isEmpty ? null : trimmedError; + final formatHints = release.formatHints .map((format) => format.trim().toUpperCase()) .where((format) => format.isNotEmpty) .join(', '); + final details = [ release.indexer.trim(), if (formatHints.isNotEmpty) formatHints, if (release.sizeBytes != null) _formatBytes(release.sizeBytes!), if (release.seeders != null) '${release.seeders} seeders', ].where((detail) => detail.isNotEmpty).toList(); + final semanticLabel = _semanticLabel(release.title, details, error); return Semantics( diff --git a/app/lib/widgets/opds/catalog_editor.dart b/app/lib/widgets/opds/catalog_editor.dart index c6e0b3c8..fac4de4b 100644 --- a/app/lib/widgets/opds/catalog_editor.dart +++ b/app/lib/widgets/opds/catalog_editor.dart @@ -17,6 +17,7 @@ class CatalogEditor extends StatefulWidget { static Future show(BuildContext context, {required OpdsCatalogs catalogs, OpdsCatalog? catalog}) async { final busy = ValueNotifier(false); + try { await showGuardedModalBottomSheet( context: context, @@ -44,6 +45,7 @@ class _CatalogEditorState extends State { bool _clearCredentials = false; bool _saving = false; String? _error; + @override void initState() { super.initState(); @@ -60,28 +62,42 @@ class _CatalogEditorState extends State { } Future _save() async { - if (_saving) return; - if (!_form.currentState!.validate()) return; + if (_saving) { + return; + } + + if (!_form.currentState!.validate()) { + return; + } + widget._busy?.value = true; + setState(() { _saving = true; _error = null; }); + try { if (_scope != widget.catalogs.scope) { throw const OpdsException('The active account changed. Close this editor and try again.'); } + final uri = OpdsHttpClient.validateUri(Uri.parse(_url.text.trim())); final changedOrigin = widget.catalog != null && widget.catalog!.uri.origin != uri.origin; + final credentials = !_clearCredentials && _username.text.isNotEmpty ? OpdsCredentials(username: _username.text, password: _password.text) : null; + await widget.catalogs.save( OpdsCatalog(id: widget.catalog?.id ?? const Uuid().v4(), name: _name.text.trim(), uri: uri), credentials: credentials, clearCredentials: _clearCredentials || changedOrigin, ); - if (mounted) Navigator.of(context).pop(); + + if (mounted) { + Navigator.of(context).pop(); + } } catch (error) { if (mounted) { setState(() { diff --git a/app/lib/widgets/opds/catalog_source_tile.dart b/app/lib/widgets/opds/catalog_source_tile.dart index bd8ae253..e6648ce6 100644 --- a/app/lib/widgets/opds/catalog_source_tile.dart +++ b/app/lib/widgets/opds/catalog_source_tile.dart @@ -21,6 +21,7 @@ class CatalogSourceTile extends StatelessWidget { Widget build(BuildContext context) { final mobile = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; final colors = Theme.of(context).colorScheme; + final tile = ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), title: Text(catalog.name), @@ -37,7 +38,10 @@ class CatalogSourceTile extends StatelessWidget { ], ), ); - if (!mobile) return tile; + + if (!mobile) { + return tile; + } return ClipRect( child: Dismissible( @@ -52,6 +56,7 @@ class CatalogSourceTile extends StatelessWidget { onRemove(); } // Swipes open a sheet; only its explicit confirmation may remove a source. + return false; }, background: _swipeBackground( diff --git a/app/lib/widgets/opds/opds_download_actions.dart b/app/lib/widgets/opds/opds_download_actions.dart index 037b4e08..4dc1e376 100644 --- a/app/lib/widgets/opds/opds_download_actions.dart +++ b/app/lib/widgets/opds/opds_download_actions.dart @@ -8,21 +8,32 @@ import 'package:provider/provider.dart'; /// A retry may use corrected credentials, but never a different catalog URL or /// a job removed by an account switch. Open format sheets retain stricter checks. Future retryOpdsDownload(BuildContext context, OpdsDownloadJob job) async { - if (!context.mounted) return; + if (!context.mounted) { + return; + } + final catalogs = context.read(); final downloads = context.read(); final scope = catalogs.scope; final revision = catalogs.revision; - if (!downloads.jobs.contains(job)) return; + + if (!downloads.jobs.contains(job)) { + return; + } + try { final catalog = catalogs.find(job.catalog.id); + if (catalog == null || catalog.uri != job.catalog.uri) { throw const OpdsException('The catalog changed. Open the book again to choose a current download.'); } + final credentials = await catalogs.credentials(catalog.id); + if (!context.mounted || catalogs.scope != scope || catalogs.revision != revision || !downloads.jobs.contains(job)) { return; } + await downloads.start(catalog, job.publication, job.link, credentials: credentials); } catch (error) { if (context.mounted) { diff --git a/app/lib/widgets/opds/opds_download_panel.dart b/app/lib/widgets/opds/opds_download_panel.dart index 815d3318..9321eda2 100644 --- a/app/lib/widgets/opds/opds_download_panel.dart +++ b/app/lib/widgets/opds/opds_download_panel.dart @@ -29,6 +29,7 @@ class OpdsDownloadsButton extends StatelessWidget { builder: (context, _) { final active = downloads.jobs.where((job) => job.isActive).length; final failed = downloads.jobs.any((job) => job.status == OpdsDownloadStatus.failed); + final icon = Badge( isLabelVisible: active > 0, label: Text('$active'), @@ -37,9 +38,11 @@ class OpdsDownloadsButton extends StatelessWidget { color: failed ? Theme.of(context).colorScheme.error : null, ), ); + if (compact ?? MediaQuery.sizeOf(context).width < 600) { return IconButton(tooltip: 'Downloads', onPressed: () => _show(context), icon: icon); } + return Tooltip( message: 'Downloads', child: TextButton.icon(onPressed: () => _show(context), icon: icon, label: const Text('Downloads')), @@ -58,9 +61,11 @@ class _DownloadsList extends StatelessWidget { animation: downloads, builder: (context, _) { final jobs = downloads.jobs; + if (jobs.isEmpty) { return const EmptyState.compact(icon: Icons.downloading_outlined, title: 'No downloads yet'); } + return ListView.separated( shrinkWrap: true, padding: EdgeInsets.zero, @@ -76,6 +81,7 @@ class _DownloadsList extends StatelessWidget { Widget _job(BuildContext context, OpdsDownloadJob job) { final theme = Theme.of(context); + final info = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -89,14 +95,19 @@ class _DownloadsList extends StatelessWidget { ), ], ); + return Column( key: ValueKey(job.key), crossAxisAlignment: CrossAxisAlignment.start, children: [ LayoutBuilder( builder: (context, constraints) { - if (!job.isCancellable && job.isActive) return info; + if (!job.isCancellable && job.isActive) { + return info; + } + final actions = _actions(context, job); + if (job.isCancellable || constraints.maxWidth >= 300 * MediaQuery.textScalerOf(context).scale(1)) { return Row( children: [ @@ -106,6 +117,7 @@ class _DownloadsList extends StatelessWidget { ], ); } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/app/lib/widgets/opds/opds_feed_view.dart b/app/lib/widgets/opds/opds_feed_view.dart index 71ddbc16..9ed1c6bc 100644 --- a/app/lib/widgets/opds/opds_feed_view.dart +++ b/app/lib/widgets/opds/opds_feed_view.dart @@ -27,6 +27,7 @@ class OpdsFeedView extends StatelessWidget { this.status, this.isRefreshing = false, }); + final OpdsCatalog catalog; final String? Function(OpdsPublication)? libraryBookId; final OpdsFeed feed; @@ -49,9 +50,11 @@ class OpdsFeedView extends StatelessWidget { final text = Theme.of(context).textTheme; final colors = Theme.of(context).colorScheme; final hasBooks = feed.publications.isNotEmpty || feed.groups.any((group) => group.publications.isNotEmpty); + return LayoutBuilder( builder: (context, constraints) { final layout = bookGridLayout(constraints.maxWidth); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -224,6 +227,7 @@ class OpdsFeedView extends StatelessWidget { itemCount: navigation.length, itemBuilder: (_, index) { final link = navigation[index]; + return Material( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(AppRadius.sm), diff --git a/app/lib/widgets/opds/opds_publication_details.dart b/app/lib/widgets/opds/opds_publication_details.dart index 1b386824..c59904a4 100644 --- a/app/lib/widgets/opds/opds_publication_details.dart +++ b/app/lib/widgets/opds/opds_publication_details.dart @@ -26,6 +26,7 @@ class OpdsPublicationDetails extends StatelessWidget { this.credentials, this.resolving = false, }); + final OpdsCatalog catalog; final OpdsPublication publication; final OpdsHttpClient httpClient; @@ -44,6 +45,7 @@ class OpdsPublicationDetails extends StatelessWidget { final theme = Theme.of(context); final bookId = downloads.libraryBookId(catalog, publication); final actionStyle = bookDetailsActionStyle(context); + void showOptions() => showOpdsSheet( context, title: 'Download options', @@ -55,6 +57,7 @@ class OpdsPublicationDetails extends StatelessWidget { onNavigate: onNavigate, ), ); + final cover = ClipRRect( borderRadius: BorderRadius.circular(AppRadius.lg), child: OpdsCover( @@ -66,6 +69,7 @@ class OpdsPublicationDetails extends StatelessWidget { height: desktop ? 360 : 270, ), ); + final metadata = Column( crossAxisAlignment: desktop ? CrossAxisAlignment.start : CrossAxisAlignment.center, children: [ @@ -91,11 +95,11 @@ class OpdsPublicationDetails extends StatelessWidget { children: [ FilledButton.icon( style: actionStyle, - onPressed: bookId != null - ? () => context.go('/library/details/${Uri.encodeComponent(bookId)}') - : resolving - ? null - : showOptions, + onPressed: switch (bookId) { + final bookId? => () => context.go('/library/details/${Uri.encodeComponent(bookId)}'), + null when resolving => null, + null => showOptions, + }, icon: Icon(bookId != null ? Icons.menu_book_outlined : Icons.add), label: Text(bookId != null ? 'Open book' : 'Add to library'), ), @@ -109,7 +113,9 @@ class OpdsPublicationDetails extends StatelessWidget { ), ], ); + final information = OpdsPublicationInformation(catalog: catalog, publication: publication); + if (!desktop) { return DefaultTabController( length: 1, @@ -137,6 +143,7 @@ class OpdsPublicationDetails extends StatelessWidget { ), ); } + return SingleChildScrollView( key: PageStorageKey('publication/${catalog.id}/${publication.id}'), child: Column( @@ -173,11 +180,13 @@ class _DownloadOptions extends StatefulWidget { required this.onDownload, required this.onNavigate, }); + final OpdsCatalog catalog; final OpdsPublication publication; final OpdsDownloads downloads; final ValueChanged onDownload; final ValueChanged onNavigate; + @override State<_DownloadOptions> createState() => _DownloadOptionsState(); } @@ -190,6 +199,7 @@ class _DownloadOptionsState extends State<_DownloadOptions> { final links = widget.publication.links.where((link) => link.isAcquisition); final supported = links.where(OpdsDownloads.supports).toList(); final unsupported = links.where((link) => !OpdsDownloads.supports(link)).toList(); + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -265,11 +275,14 @@ class _DownloadOptionsState extends State<_DownloadOptions> { final jobs = widget.downloads.jobs.where((job) => job.key == key); final job = jobs.isEmpty ? null : jobs.first; final format = link.supportedExtension!.toUpperCase(); + final bookId = widget.downloads.libraryBookId(widget.catalog, widget.publication, link: link) ?? (widget.downloads.library == null && job?.status == OpdsDownloadStatus.complete ? job?.bookId : null); + final complete = bookId != null; final active = job?.isActive ?? false; + return Container( decoration: BoxDecoration( border: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), @@ -292,25 +305,27 @@ class _DownloadOptionsState extends State<_DownloadOptions> { ), ], ); + final button = FilledButton.icon( style: FilledButton.styleFrom(minimumSize: const Size(0, 40)), - onPressed: active - ? null - : complete - ? () { - Navigator.of(context).pop(); - context.go('/library/details/${Uri.encodeComponent(bookId)}'); - } - : () => widget.onDownload(link), + onPressed: switch (active) { + true => null, + false when complete => () { + Navigator.of(context).pop(); + context.go('/library/details/${Uri.encodeComponent(bookId)}'); + }, + false => () => widget.onDownload(link), + }, icon: Icon(complete ? Icons.check : Icons.download_outlined, size: IconSizes.small), label: Text( - complete - ? 'Open book' - : job?.error != null - ? 'Retry download' - : 'Download $format', + switch (complete) { + true => 'Open book', + false when job?.error != null => 'Retry download', + false => 'Download $format', + }, ), ); + return constraints.maxWidth < 300 * MediaQuery.textScalerOf(context).scale(1) ? Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/app/lib/widgets/opds/opds_publication_information.dart b/app/lib/widgets/opds/opds_publication_information.dart index 8f768f5e..78fe4770 100644 --- a/app/lib/widgets/opds/opds_publication_information.dart +++ b/app/lib/widgets/opds/opds_publication_information.dart @@ -13,6 +13,7 @@ class OpdsPublicationInformation extends StatelessWidget { Widget build(BuildContext context) { final content = OpdsPublicationContent.from(catalog, publication); final theme = Theme.of(context); + final description = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -30,6 +31,7 @@ class OpdsPublicationInformation extends StatelessWidget { ), ], ); + final additionalDetails = [ if (content.subjects.isNotEmpty) ...[ const SizedBox(height: Spacing.lg), @@ -44,6 +46,7 @@ class OpdsPublicationInformation extends StatelessWidget { ), ], ]; + final information = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -52,6 +55,7 @@ class OpdsPublicationInformation extends StatelessWidget { BookMetadataRows(entries: content.information.entries.map((entry) => (entry.key, entry.value)).toList()), ], ); + return Padding( padding: const EdgeInsets.symmetric(vertical: Spacing.md), child: LayoutBuilder( @@ -62,6 +66,7 @@ class OpdsPublicationInformation extends StatelessWidget { children: [description, ...additionalDetails], ); } + if (constraints.maxWidth >= 700 * MediaQuery.textScalerOf(context).scale(1)) { return Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -78,6 +83,7 @@ class OpdsPublicationInformation extends StatelessWidget { ], ); } + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/app/lib/widgets/opds/opds_publication_tile.dart b/app/lib/widgets/opds/opds_publication_tile.dart index e9265404..8604db8d 100644 --- a/app/lib/widgets/opds/opds_publication_tile.dart +++ b/app/lib/widgets/opds/opds_publication_tile.dart @@ -18,6 +18,7 @@ class OpdsPublicationTile extends StatelessWidget { this.isGridView = false, this.inLibrary = false, }); + final OpdsCatalog catalog; final OpdsPublication publication; final VoidCallback onOpen; @@ -39,14 +40,17 @@ class OpdsPublicationTile extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final acquisition = publication.links.where((link) => link.isAcquisition); + final formats = acquisition .map((link) => link.supportedExtension?.toUpperCase()) .whereType() .toSet() .join(' · '); + final edition = acquisition.isEmpty ? null : acquisition.first.title; final formatCaption = edition ?? (formats.isEmpty ? 'View details' : formats); final caption = inLibrary ? 'In library · $formatCaption' : formatCaption; + final info = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -67,6 +71,7 @@ class OpdsPublicationTile extends StatelessWidget { ), ], ); + return Material( color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(AppRadius.md), @@ -108,12 +113,14 @@ class OpdsCover extends StatefulWidget { this.width = 40, this.height = 56, }); + final OpdsCatalog catalog; final Uri? uri; final OpdsHttpClient httpClient; final OpdsCredentials? credentials; final double width; final double height; + @override State createState() => _OpdsCoverState(); } @@ -122,6 +129,7 @@ class _OpdsCoverState extends State { OpdsCancellation _token = OpdsCancellation(); Uint8List? _bytes; bool _loading = false; + @override void initState() { super.initState(); @@ -131,6 +139,7 @@ class _OpdsCoverState extends State { @override void didUpdateWidget(covariant OpdsCover oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.uri != widget.uri || oldWidget.catalog != widget.catalog || oldWidget.credentials != widget.credentials) { @@ -143,35 +152,57 @@ class _OpdsCoverState extends State { final token = _token = OpdsCancellation(); _bytes = null; _loading = widget.uri != null && ['http', 'https'].contains(widget.uri!.scheme); - if (!_loading) return; + + if (!_loading) { + return; + } + final cache = widget.httpClient.cache; OpdsCacheToken? cacheToken; + try { cacheToken = cache?.capture(widget.catalog, widget.uri!); final cached = cacheToken == null ? null : cache?.read(cacheToken); + if (cached != null && cached.response.headers['content-type']?.startsWith('image/') == true) { _bytes = cached.response.bytes; _loading = false; - if (DateTime.now().difference(cached.fetchedAt) < const Duration(days: 1)) return; + + if (DateTime.now().difference(cached.fetchedAt) < const Duration(days: 1)) { + return; + } } + final response = await widget.httpClient.get( widget.catalog, widget.uri!, credentials: widget.credentials, cancellation: token, ); - if (!mounted || token.isCancelled || (cacheToken != null && !cache!.isCurrent(cacheToken))) return; + + if (!mounted || token.isCancelled || (cacheToken != null && !cache!.isCurrent(cacheToken))) { + return; + } + setState(() => _bytes = response.bytes); + if (cacheToken != null && response.headers['content-type']?.startsWith('image/') == true) { await cache!.write(cacheToken, response); } } on OpdsAuthorizationException { - if (mounted && !token.isCancelled) setState(() => _bytes = null); - if (cacheToken != null) await cache!.remove(cacheToken); + if (mounted && !token.isCancelled) { + setState(() => _bytes = null); + } + + if (cacheToken != null) { + await cache!.remove(cacheToken); + } } catch (_) { // Catalog artwork is optional; keep the themed cover placeholder. } finally { - if (mounted && !token.isCancelled && _loading) setState(() => _loading = false); + if (mounted && !token.isCancelled && _loading) { + setState(() => _loading = false); + } } } @@ -184,6 +215,7 @@ class _OpdsCoverState extends State { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final placeholder = ColoredBox( color: colors.surfaceContainerHighest, child: Center( @@ -194,20 +226,21 @@ class _OpdsCoverState extends State { ), ), ); + return SizedBox( width: widget.width, height: widget.height, - child: _loading - ? const CoverLoadingPlaceholder() - : _bytes == null - ? placeholder - : Image.memory( - _bytes!, - fit: BoxFit.cover, - frameBuilder: (_, child, frame, wasSynchronouslyLoaded) => - wasSynchronouslyLoaded || frame != null ? child : const CoverLoadingPlaceholder(), - errorBuilder: (_, _, _) => placeholder, - ), + child: switch (_loading) { + true => const CoverLoadingPlaceholder(), + false when _bytes == null => placeholder, + false => Image.memory( + _bytes!, + fit: BoxFit.cover, + frameBuilder: (_, child, frame, wasSynchronouslyLoaded) => + wasSynchronouslyLoaded || frame != null ? child : const CoverLoadingPlaceholder(), + errorBuilder: (_, _, _) => placeholder, + ), + }, ); } } diff --git a/app/lib/widgets/opds/opds_sheet.dart b/app/lib/widgets/opds/opds_sheet.dart index e699de7c..0c9a56bc 100644 --- a/app/lib/widgets/opds/opds_sheet.dart +++ b/app/lib/widgets/opds/opds_sheet.dart @@ -17,6 +17,7 @@ Future showOpdsSheet( EdgeInsetsGeometry contentPadding = const EdgeInsets.all(Spacing.lg), }) { final reduceAnimations = AppMotion.disabled(context); + return showModalBottomSheet( context: context, useRootNavigator: true, diff --git a/app/lib/widgets/profile/about_settings_section.dart b/app/lib/widgets/profile/about_settings_section.dart index 7436eb22..f9212f41 100644 --- a/app/lib/widgets/profile/about_settings_section.dart +++ b/app/lib/widgets/profile/about_settings_section.dart @@ -17,6 +17,7 @@ class AboutSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } diff --git a/app/lib/widgets/profile/accessibility_settings_section.dart b/app/lib/widgets/profile/accessibility_settings_section.dart index 09c97b12..b3e2ec01 100644 --- a/app/lib/widgets/profile/accessibility_settings_section.dart +++ b/app/lib/widgets/profile/accessibility_settings_section.dart @@ -16,6 +16,7 @@ class AccessibilitySettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } diff --git a/app/lib/widgets/profile/account_settings_section.dart b/app/lib/widgets/profile/account_settings_section.dart index 5d6437c6..f56443cc 100644 --- a/app/lib/widgets/profile/account_settings_section.dart +++ b/app/lib/widgets/profile/account_settings_section.dart @@ -17,6 +17,7 @@ class AccountSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } @@ -137,9 +138,11 @@ class AccountSettingsSection extends StatelessWidget { String _getInitials(String name) { final parts = name.split(' '); + if (parts.length >= 2) { return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); } + return name.isNotEmpty ? name[0].toUpperCase() : '?'; } } diff --git a/app/lib/widgets/profile/appearance_settings_section.dart b/app/lib/widgets/profile/appearance_settings_section.dart index a6145600..ce0c10ce 100644 --- a/app/lib/widgets/profile/appearance_settings_section.dart +++ b/app/lib/widgets/profile/appearance_settings_section.dart @@ -31,6 +31,7 @@ class AppearanceSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } @@ -85,7 +86,9 @@ class AppearanceSettingsSection extends StatelessWidget { groupValue: prefs.themeModePref, // ignore: deprecated_member_use onChanged: (newValue) { - if (newValue != null) prefs.themeModePref = newValue; + if (newValue != null) { + prefs.themeModePref = newValue; + } }, ), onTap: () => prefs.themeModePref = value, diff --git a/app/lib/widgets/profile/developer_settings_section.dart b/app/lib/widgets/profile/developer_settings_section.dart index b47d5225..0b83527b 100644 --- a/app/lib/widgets/profile/developer_settings_section.dart +++ b/app/lib/widgets/profile/developer_settings_section.dart @@ -15,6 +15,7 @@ class DeveloperSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } diff --git a/app/lib/widgets/profile/library_settings_section.dart b/app/lib/widgets/profile/library_settings_section.dart index f407cbde..b5c9ef0b 100644 --- a/app/lib/widgets/profile/library_settings_section.dart +++ b/app/lib/widgets/profile/library_settings_section.dart @@ -13,7 +13,10 @@ class LibrarySettingsSection extends StatelessWidget { const LibrarySettingsSection({super.key, this.isDesktop = false}); static String capitalize(String s) { - if (s.isEmpty) return s; + if (s.isEmpty) { + return s; + } + return s[0].toUpperCase() + s.substring(1); } @@ -25,6 +28,7 @@ class LibrarySettingsSection extends StatelessWidget { 'last_read': 'Last read', 'rating': 'Rating', }; + return labels[value] ?? value; } @@ -33,6 +37,7 @@ class LibrarySettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } @@ -149,7 +154,9 @@ class LibrarySettingsSection extends StatelessWidget { return DropdownMenuEntry(value: option, label: displayLabel); }).toList(), onSelected: (selected) { - if (selected != null) onChanged(selected); + if (selected != null) { + onChanged(selected); + } }, ), ], diff --git a/app/lib/widgets/profile/notifications_settings_section.dart b/app/lib/widgets/profile/notifications_settings_section.dart index ab2862f5..c9e97358 100644 --- a/app/lib/widgets/profile/notifications_settings_section.dart +++ b/app/lib/widgets/profile/notifications_settings_section.dart @@ -15,6 +15,7 @@ class NotificationsSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } diff --git a/app/lib/widgets/profile/privacy_settings_section.dart b/app/lib/widgets/profile/privacy_settings_section.dart index 3248d334..3fdc1e24 100644 --- a/app/lib/widgets/profile/privacy_settings_section.dart +++ b/app/lib/widgets/profile/privacy_settings_section.dart @@ -16,6 +16,7 @@ class PrivacySettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } diff --git a/app/lib/widgets/profile/profile_header.dart b/app/lib/widgets/profile/profile_header.dart index 87790055..57c59821 100644 --- a/app/lib/widgets/profile/profile_header.dart +++ b/app/lib/widgets/profile/profile_header.dart @@ -53,15 +53,20 @@ class ProfileHeader extends StatelessWidget { /// Gets user initials from display name. String get _initials { final parts = displayName.split(' '); + if (parts.length >= 2) { return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); } + return displayName.isNotEmpty ? displayName[0].toUpperCase() : '?'; } @override Widget build(BuildContext context) { - if (isDesktopLayout) return _buildDesktopHeader(context); + if (isDesktopLayout) { + return _buildDesktopHeader(context); + } + return _buildMobileHeader(context); } diff --git a/app/lib/widgets/profile/profile_menu_item.dart b/app/lib/widgets/profile/profile_menu_item.dart index 6c268be9..a739bec9 100644 --- a/app/lib/widgets/profile/profile_menu_item.dart +++ b/app/lib/widgets/profile/profile_menu_item.dart @@ -65,7 +65,6 @@ class ProfileMenuItem extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final contentColor = isDestructive ? colorScheme.error : colorScheme.onSurface; final iconContainerColor = isDestructive ? colorScheme.errorContainer : colorScheme.surfaceContainerHighest; diff --git a/app/lib/widgets/profile/profile_picker_sheet.dart b/app/lib/widgets/profile/profile_picker_sheet.dart index 30f7434a..bce7c100 100644 --- a/app/lib/widgets/profile/profile_picker_sheet.dart +++ b/app/lib/widgets/profile/profile_picker_sheet.dart @@ -23,6 +23,7 @@ void showProfilePickerSheet( children: items.map((item) { final colorScheme = Theme.of(sheetContext).colorScheme; final isSelected = selected == item.$2; + return ListTile( title: Text(item.$1), leading: Icon( diff --git a/app/lib/widgets/profile/reading_settings_section.dart b/app/lib/widgets/profile/reading_settings_section.dart index c99fecef..e2a14bfc 100644 --- a/app/lib/widgets/profile/reading_settings_section.dart +++ b/app/lib/widgets/profile/reading_settings_section.dart @@ -13,7 +13,10 @@ class ReadingSettingsSection extends StatelessWidget { const ReadingSettingsSection({super.key, this.isDesktop = false}); static String capitalize(String s) { - if (s.isEmpty) return s; + if (s.isEmpty) { + return s; + } + return s[0].toUpperCase() + s.substring(1); } @@ -22,6 +25,7 @@ class ReadingSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktop(context); } + return _buildMobile(context); } @@ -171,6 +175,7 @@ class ReadingSettingsSection extends StatelessWidget { Row( children: highlightColors.entries.map((entry) { final isSelected = prefs.defaultHighlightColor == entry.key; + return Padding( padding: const EdgeInsets.only(right: Spacing.sm), child: GestureDetector( @@ -219,7 +224,9 @@ class ReadingSettingsSection extends StatelessWidget { return DropdownMenuEntry(value: option, label: displayLabel); }).toList(), onSelected: (selected) { - if (selected != null) onChanged(selected); + if (selected != null) { + onChanged(selected); + } }, ), ], diff --git a/app/lib/widgets/profile/storage_sync_settings_section.dart b/app/lib/widgets/profile/storage_sync_settings_section.dart index 01dd1d46..c9a65eae 100644 --- a/app/lib/widgets/profile/storage_sync_settings_section.dart +++ b/app/lib/widgets/profile/storage_sync_settings_section.dart @@ -33,13 +33,10 @@ class StorageSyncSettingsSection extends StatelessWidget { if (isDesktop) { return _buildDesktopContent(context); } + return _buildMobileSection(context); } - // ============================================================================ - // MOBILE PRESENTATION - // ============================================================================ - Widget _buildMobileSection(BuildContext context) { final controller = _storageSyncController(context); @@ -95,16 +92,14 @@ class StorageSyncSettingsSection extends StatelessWidget { ); } - // ============================================================================ - // DESKTOP PRESENTATION - // ============================================================================ - Widget _buildDesktopContent(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; final controller = _storageSyncController(context); - if (controller.isGuest) return _buildOfflineStorageSyncContent(context); + if (controller.isGuest) { + return _buildOfflineStorageSyncContent(context); + } return Column( children: [ @@ -215,13 +210,12 @@ class StorageSyncSettingsSection extends StatelessWidget { ); } - // ============================================================================ - // ACQUISITION HELPERS - // ============================================================================ - static Widget buildMobileAcquisitionSection(BuildContext context) { final auth = context.watch(); - if (auth.isOfflineMode) return const SizedBox.shrink(); + + if (auth.isOfflineMode) { + return const SizedBox.shrink(); + } return Column( key: const Key('profile-acquisition-section'), @@ -287,10 +281,6 @@ class StorageSyncSettingsSection extends StatelessWidget { }; } - // ============================================================================ - // STORAGE CONTROLLER & HELPERS - // ============================================================================ - StorageSyncController _storageSyncController(BuildContext context) { return StorageSyncController( authProvider: context.watch(), @@ -330,10 +320,6 @@ class StorageSyncSettingsSection extends StatelessWidget { ); } - // ============================================================================ - // ACTIONS & DIALOGS - // ============================================================================ - void _showManageSyncServersSheet(BuildContext context) { showModalBottomSheet( sheetAnimationStyle: AppMotion.animationStyle(context), @@ -432,7 +418,10 @@ class StorageSyncSettingsSection extends StatelessWidget { } else { await settings.updateCustomServer(server.id, urlController.text); } - if (dialogContext.mounted) Navigator.pop(dialogContext); + + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } } catch (error) { messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, @@ -453,8 +442,10 @@ class StorageSyncSettingsSection extends StatelessWidget { Future _handleReconnectSync(BuildContext context) async { final messenger = ScaffoldMessenger.of(context); final snackBarAnimationStyle = AppMotion.animationStyle(context); + try { await context.read().reconnect(); + messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, const SnackBar(content: Text('Sync reconnect requested.')), @@ -471,6 +462,7 @@ class StorageSyncSettingsSection extends StatelessWidget { final messenger = ScaffoldMessenger.of(context); final snackBarAnimationStyle = AppMotion.animationStyle(context); await context.read().retryFailed(); + messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, const SnackBar(content: Text('Media uploads will retry on the next sync.')), @@ -528,12 +520,17 @@ class StorageSyncSettingsSection extends StatelessWidget { 'This deletes the library stored on this device. This cannot be undone unless you have exported a backup.', actionLabel: 'Clear library', ); - if (!confirmed || !context.mounted) return; + + if (!confirmed || !context.mounted) { + return; + } final messenger = ScaffoldMessenger.of(context); final snackBarAnimationStyle = AppMotion.animationStyle(context); + try { await context.read().clearGuestLibrary(); + messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, const SnackBar(content: Text('Local library cleared.')), @@ -554,18 +551,24 @@ class StorageSyncSettingsSection extends StatelessWidget { 'This removes synced library data stored on this device. Your library stays on the server and will download again when sync reconnects.', actionLabel: 'Clear local copy', ); - if (!confirmed || !context.mounted) return; + + if (!confirmed || !context.mounted) { + return; + } final messenger = ScaffoldMessenger.of(context); final snackBarAnimationStyle = AppMotion.animationStyle(context); + try { final scope = context.read().activeScope; final powerSyncService = context.read(); final importService = context.read(); await powerSyncService.clearAuthenticatedCache(); + if (scope != null) { await importService.clearCoverFiles(scope); } + messenger.showSnackBar( snackBarAnimationStyle: snackBarAnimationStyle, const SnackBar(content: Text('Local copy cleared.')), diff --git a/app/lib/widgets/search/library_search_bar.dart b/app/lib/widgets/search/library_search_bar.dart index a00428fb..785ba211 100644 --- a/app/lib/widgets/search/library_search_bar.dart +++ b/app/lib/widgets/search/library_search_bar.dart @@ -35,6 +35,7 @@ class _LibrarySearchBarState extends State { @override void didUpdateWidget(LibrarySearchBar oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.initialQuery != oldWidget.initialQuery && !_focusNode.hasFocus) { _controller.text = widget.initialQuery; } @@ -61,6 +62,7 @@ class _LibrarySearchBarState extends State { Widget _buildFilterButton(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final hasActiveFilters = widget.activeFilterCount > 0; + final semanticLabel = hasActiveFilters ? 'Advanced filters, ${widget.activeFilterCount} active' : 'Advanced filters'; diff --git a/app/lib/widgets/settings/settings_row.dart b/app/lib/widgets/settings/settings_row.dart index 7b5f4c9d..5a2c9f02 100644 --- a/app/lib/widgets/settings/settings_row.dart +++ b/app/lib/widgets/settings/settings_row.dart @@ -82,18 +82,18 @@ class SettingsRow extends StatelessWidget { children: [ if (leading != null) ...[leading!, const SizedBox(width: Spacing.md)], Expanded( - child: showsValueAsTrailing - ? Text(label, style: textTheme.bodyLarge) - : value != null - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: textTheme.bodyLarge), - const SizedBox(height: 2), - Text(value!, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), - ], - ) - : Text(label, style: textTheme.bodyLarge), + child: switch (showsValueAsTrailing) { + true => Text(label, style: textTheme.bodyLarge), + false when value != null => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.bodyLarge), + const SizedBox(height: 2), + Text(value!, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), + false => Text(label, style: textTheme.bodyLarge), + }, ), if (trailing != null) trailing! diff --git a/app/lib/widgets/shared/app_bottom_sheet.dart b/app/lib/widgets/shared/app_bottom_sheet.dart index 7fe6d1f9..933b2e08 100644 --- a/app/lib/widgets/shared/app_bottom_sheet.dart +++ b/app/lib/widgets/shared/app_bottom_sheet.dart @@ -42,6 +42,7 @@ class AppBottomSheet extends StatelessWidget { Widget build(BuildContext context) { final mobile = MediaQuery.sizeOf(context).width < Breakpoints.tablet; final expandable = mobile && expandOnScroll && ModalRoute.of(context) is ModalBottomSheetRoute; + Widget buildFrame(ScrollController? controller, double availableHeight) => SafeArea( top: false, bottom: footer == null, @@ -51,6 +52,7 @@ class AppBottomSheet extends StatelessWidget { // A short, content-sized sheet still needs normal header spacing. // Compress only when the available viewport is actually small. availableHeight < 280 || (MediaQuery.textScalerOf(context).scale(16) > 20 && availableHeight < 600); + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -98,6 +100,7 @@ class AppBottomSheet extends StatelessWidget { }, ), ); + return Padding( padding: EdgeInsets.only(bottom: avoidKeyboard ? MediaQuery.viewInsetsOf(context).bottom : 0), child: LayoutBuilder( diff --git a/app/lib/widgets/shared/app_date_picker.dart b/app/lib/widgets/shared/app_date_picker.dart index 9c2de65e..1e638ce7 100644 --- a/app/lib/widgets/shared/app_date_picker.dart +++ b/app/lib/widgets/shared/app_date_picker.dart @@ -12,6 +12,7 @@ Future showAppDatePicker({ if (!AppMotion.disabled(context)) { return showDatePicker(context: context, initialDate: initialDate, firstDate: firstDate, lastDate: lastDate); } + return showDialog( context: context, animationStyle: AnimationStyle.noAnimation, @@ -47,6 +48,7 @@ Future showAppDateRangePicker({ builder: builder, ); } + return showDialog( context: context, useRootNavigator: useRootNavigator, @@ -63,6 +65,7 @@ Future showAppDateRangePicker({ cancelText: cancelText, confirmText: saveText ?? confirmText, ); + return builder == null ? dialog : builder(context, dialog); }, ); diff --git a/app/lib/widgets/shared/app_drawer.dart b/app/lib/widgets/shared/app_drawer.dart index 3fe7d42b..d20b6cbb 100644 --- a/app/lib/widgets/shared/app_drawer.dart +++ b/app/lib/widgets/shared/app_drawer.dart @@ -18,13 +18,22 @@ class AppDrawerScope extends InheritedWidget { /// Opens the same drawer without a sliding transition on e-ink displays. void openAppDrawer(BuildContext context, ScaffoldState? scaffold) { scaffold = AppDrawerScope.maybeOf(context) ?? scaffold; - if (scaffold == null) return; + + if (scaffold == null) { + return; + } + if (!AppMotion.disabled(context)) { scaffold.openDrawer(); return; } + final drawer = scaffold.widget.drawer; - if (drawer == null) return; + + if (drawer == null) { + return; + } + showDialog( context: context, useSafeArea: false, diff --git a/app/lib/widgets/shared/app_motion_control.dart b/app/lib/widgets/shared/app_motion_control.dart index f6cab10a..623171bc 100644 --- a/app/lib/widgets/shared/app_motion_control.dart +++ b/app/lib/widgets/shared/app_motion_control.dart @@ -26,14 +26,22 @@ class _AppMotionControlState extends State { final disabled = AppMotion.disabled(context); final focusNode = widget.focusNode ?? _ownedFocusNode; final key = ValueKey((disabled, disabled ? widget.value : null, disabled ? widget.enabled : null)); + if (_previousKey != null && key != _previousKey && focusNode.hasFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && focusNode.canRequestFocus) focusNode.requestFocus(); + if (mounted && focusNode.canRequestFocus) { + focusNode.requestFocus(); + } }); } + _previousKey = key; final child = KeyedSubtree(key: key, child: widget.builder(focusNode)); - if (!disabled) return child; + + if (!disabled) { + return child; + } + return TickerMode( enabled: false, child: ListenableBuilder( diff --git a/app/lib/widgets/shared/bottom_sheet_actions.dart b/app/lib/widgets/shared/bottom_sheet_actions.dart index e168f0a6..66bf113b 100644 --- a/app/lib/widgets/shared/bottom_sheet_actions.dart +++ b/app/lib/widgets/shared/bottom_sheet_actions.dart @@ -14,11 +14,14 @@ class BottomSheetActions extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final primaryShape = theme.filledButtonTheme.style?.shape ?? const FilledButton(onPressed: null, child: SizedBox()).defaultStyleOf(context).shape; + final compact = MediaQuery.sizeOf(context).width < Breakpoints.tablet; final enlargedText = MediaQuery.textScalerOf(context).scale(16) > 20; + final style = ButtonStyle( visualDensity: VisualDensity.standard, minimumSize: const WidgetStatePropertyAll(Size(80, ComponentSizes.buttonHeightMobile)), @@ -26,6 +29,7 @@ class BottomSheetActions extends StatelessWidget { EdgeInsets.symmetric(horizontal: compact ? Spacing.md : Spacing.buttonPaddingHorizontal, vertical: Spacing.sm), ), ); + return Theme( data: theme.copyWith( filledButtonTheme: FilledButtonThemeData(style: style.merge(theme.filledButtonTheme.style)), diff --git a/app/lib/widgets/shared/bottom_sheet_header.dart b/app/lib/widgets/shared/bottom_sheet_header.dart index 14c0c9cf..d4981c6d 100644 --- a/app/lib/widgets/shared/bottom_sheet_header.dart +++ b/app/lib/widgets/shared/bottom_sheet_header.dart @@ -37,10 +37,13 @@ class BottomSheetHeader extends StatelessWidget { textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ); + final cancelButton = TextButton(onPressed: canCancel ? onCancel : null, child: Text(cancelLabel)); + final saveButton = onSave == null ? const SizedBox.shrink() : FilledButton(key: saveButtonKey, onPressed: canSave ? onSave : null, child: Text(saveLabel)); + if (stacked) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -55,6 +58,7 @@ class BottomSheetHeader extends StatelessWidget { ], ); } + return Row( children: [ Expanded( diff --git a/app/lib/widgets/shared/empty_state.dart b/app/lib/widgets/shared/empty_state.dart index 3fae1e1e..33d9c6a2 100644 --- a/app/lib/widgets/shared/empty_state.dart +++ b/app/lib/widgets/shared/empty_state.dart @@ -115,6 +115,7 @@ class EmptyStateAction extends StatelessWidget { ), visualDensity: VisualDensity.standard, ); + return icon == null ? FilledButton(onPressed: onPressed, style: style, child: Text(label)) : FilledButton.icon( diff --git a/app/lib/widgets/shared/expandable_bottom_sheet.dart b/app/lib/widgets/shared/expandable_bottom_sheet.dart index 300c6020..be612c48 100644 --- a/app/lib/widgets/shared/expandable_bottom_sheet.dart +++ b/app/lib/widgets/shared/expandable_bottom_sheet.dart @@ -21,17 +21,34 @@ class _ExpandableBottomSheetState extends State { bool _measurementScheduled = false; void _measureContent() { - if (_measurementScheduled) return; + if (_measurementScheduled) { + return; + } + _measurementScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { _measurementScheduled = false; - if (!mounted) return; + + if (!mounted) { + return; + } + final box = _contentKey.currentContext?.findRenderObject(); final controller = _scrollController; - if (box is! RenderBox || !box.hasSize) return; + + if (box is! RenderBox || !box.hasSize) { + return; + } + final position = controller != null && controller.positions.length == 1 ? controller.position : null; - if (position != null && !position.hasContentDimensions) return; + + if (position != null && !position.hasContentDimensions) { + return; + } + final height = box.size.height + math.max(0, position?.maxScrollExtent ?? 0); + if (_contentHeight == null || (height - _contentHeight!).abs() > 1) { setState(() => _contentHeight = height); } @@ -46,6 +63,7 @@ class _ExpandableBottomSheetState extends State { // When the keyboard leaves little room, use all of the available height. final initialSize = math.min(availableHeight < 400 ? 1.0 : .8, maxSize); _measureContent(); + return DraggableScrollableSheet( expand: false, initialChildSize: initialSize, @@ -54,9 +72,13 @@ class _ExpandableBottomSheetState extends State { shouldCloseOnMinExtent: widget.canClose, builder: (context, controller) { _scrollController = controller; + return NotificationListener( onNotification: (notification) { - if (notification.depth == 0) _measureContent(); + if (notification.depth == 0) { + _measureContent(); + } + return false; }, child: NotificationListener( diff --git a/app/lib/widgets/shared/persistent_save.dart b/app/lib/widgets/shared/persistent_save.dart index dc9c638a..58bedf53 100644 --- a/app/lib/widgets/shared/persistent_save.dart +++ b/app/lib/widgets/shared/persistent_save.dart @@ -8,8 +8,12 @@ mixin PersistentSave on State { bool isSaving = false; Future persist(FutureOr Function() save) async { - if (isSaving) return false; + if (isSaving) { + return false; + } + setState(() => isSaving = true); + try { await save(); return mounted; @@ -20,9 +24,12 @@ mixin PersistentSave on State { const SnackBar(content: Text('Could not save changes. Please try again.')), ); } + return false; } finally { - if (mounted) setState(() => isSaving = false); + if (mounted) { + setState(() => isSaving = false); + } } } } diff --git a/app/lib/widgets/shared/searchable_book_field.dart b/app/lib/widgets/shared/searchable_book_field.dart index 4a8fb621..56dcd1d5 100644 --- a/app/lib/widgets/shared/searchable_book_field.dart +++ b/app/lib/widgets/shared/searchable_book_field.dart @@ -17,6 +17,7 @@ class SearchableBookField extends FormField { 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 @@ -30,6 +31,7 @@ class SearchableBookField extends FormField { sheetAnimationStyle: AppMotion.animationStyle(field.context), builder: (_) => _BookSearchSheet(books: books, selectedId: field.value), ); + if (book != null && field.mounted) { field.didChange(book.id); onChanged(book.id); @@ -53,21 +55,25 @@ 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), @@ -89,6 +95,7 @@ class _BookSearchSheetState extends State<_BookSearchSheet> { itemCount: books.length, itemBuilder: (context, index) { final book = books[index]; + return ListTile( contentPadding: EdgeInsets.zero, title: Text(book.title, maxLines: 2, overflow: TextOverflow.ellipsis), diff --git a/app/lib/widgets/shared/searchable_books_field.dart b/app/lib/widgets/shared/searchable_books_field.dart index 977b5afb..488a1e21 100644 --- a/app/lib/widgets/shared/searchable_books_field.dart +++ b/app/lib/widgets/shared/searchable_books_field.dart @@ -17,6 +17,7 @@ class SearchableBooksField extends FormField> { 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: [ @@ -35,6 +36,7 @@ class SearchableBooksField extends FormField> { sheetAnimationStyle: AppMotion.animationStyle(field.context), builder: (_) => _BooksSearchSheet(books: books, selected: selected), ); + if (result != null && field.mounted) { field.didChange(result); onChanged(result); @@ -86,6 +88,7 @@ class _BooksSearchSheet extends StatefulWidget { const _BooksSearchSheet({required this.books, required this.selected}); final List books; final List selected; + @override State<_BooksSearchSheet> createState() => _BooksSearchSheetState(); } @@ -93,15 +96,18 @@ class _BooksSearchSheet extends StatefulWidget { 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), @@ -123,6 +129,7 @@ class _BooksSearchSheetState extends State<_BooksSearchSheet> { itemCount: books.length, itemBuilder: (context, index) { final book = books[index]; + return CheckboxListTile( contentPadding: EdgeInsets.zero, controlAffinity: ListTileControlAffinity.leading, diff --git a/app/lib/widgets/shared/sheet_choice_buttons.dart b/app/lib/widgets/shared/sheet_choice_buttons.dart index 289e189c..aa6c8b4c 100644 --- a/app/lib/widgets/shared/sheet_choice_buttons.dart +++ b/app/lib/widgets/shared/sheet_choice_buttons.dart @@ -26,6 +26,7 @@ class SheetChoiceButtons extends StatelessWidget { style: const ButtonStyle(visualDensity: VisualDensity.compact), ); } + return Wrap( spacing: Spacing.sm, runSpacing: Spacing.sm, diff --git a/app/lib/widgets/shared/static_date_picker.dart b/app/lib/widgets/shared/static_date_picker.dart index c2ff7f4c..eaae1b75 100644 --- a/app/lib/widgets/shared/static_date_picker.dart +++ b/app/lib/widgets/shared/static_date_picker.dart @@ -48,9 +48,17 @@ class _StaticDatePickerState extends State { _start = initialStart == null ? null : DateUtils.dateOnly(initialStart); _end = widget.initialRange == null ? null : DateUtils.dateOnly(widget.initialRange!.end); var displayed = _start ?? DateTime.now(); - if (displayed.isBefore(_first)) displayed = _first; - if (displayed.isAfter(_last)) displayed = _last; + + if (displayed.isBefore(_first)) { + displayed = _first; + } + + if (displayed.isAfter(_last)) { + displayed = _last; + } + _month = DateTime(displayed.year, displayed.month); + _input = widget.initialEntryMode == DatePickerEntryMode.input || widget.initialEntryMode == DatePickerEntryMode.inputOnly; @@ -68,29 +76,45 @@ class _StaticDatePickerState extends State { void _save() { if (_input) { _formKey.currentState!.save(); - if (!_formKey.currentState!.validate()) return; + + if (!_formKey.currentState!.validate()) { + return; + } } - if (_start == null || (widget.selectRange && (_end == null || _end!.isBefore(_start!)))) return; + + if (_start == null || (widget.selectRange && (_end == null || _end!.isBefore(_start!)))) { + return; + } + Navigator.of(context).pop(widget.selectRange ? DateTimeRange(start: _start!, end: _end!) : _start); } void _toggleEntryMode() { if (_input) { _formKey.currentState!.save(); - if (!_formKey.currentState!.validate()) return; + + if (!_formKey.currentState!.validate()) { + return; + } } + setState(() { _input = !_input; - if (_start != null) _month = DateTime(_start!.year, _start!.month); + + if (_start != null) { + _month = DateTime(_start!.year, _start!.month); + } }); } @override Widget build(BuildContext context) { final labels = MaterialLocalizations.of(context); + final allowModeSwitch = widget.initialEntryMode != DatePickerEntryMode.calendarOnly && widget.initialEntryMode != DatePickerEntryMode.inputOnly; + final content = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, @@ -116,12 +140,13 @@ class _StaticDatePickerState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Text( - widget.selectRange - ? '${_start == null ? labels.dateRangeStartLabel : labels.formatMediumDate(_start!)} – ' - '${_end == null ? labels.dateRangeEndLabel : labels.formatMediumDate(_end!)}' - : _start == null - ? '' - : labels.formatMediumDate(_start!), + switch (widget.selectRange) { + true => + '${_start == null ? labels.dateRangeStartLabel : labels.formatMediumDate(_start!)} – ' + '${_end == null ? labels.dateRangeEndLabel : labels.formatMediumDate(_end!)}', + false when _start == null => '', + false => labels.formatMediumDate(_start!), + }, style: Theme.of(context).textTheme.headlineSmall, ), ), @@ -142,6 +167,7 @@ class _StaticDatePickerState extends State { ), ], ); + if (widget.selectRange) { return Dialog.fullscreen( child: SafeArea( @@ -152,6 +178,7 @@ class _StaticDatePickerState extends State { ), ); } + return Dialog(child: SizedBox(width: 328, child: content)); } @@ -187,6 +214,7 @@ class _StaticDatePickerState extends State { Widget _buildCalendar(MaterialLocalizations labels) { final previous = DateTime(_month.year, _month.month - 1); final next = DateTime(_month.year, _month.month + 1); + return Column( children: [ Padding( @@ -231,8 +259,15 @@ class _StaticDatePickerState extends State { selectedDate: _month, onChanged: (date) => setState(() { var month = DateTime(date.year, _month.month); - if (month.isBefore(DateTime(_first.year, _first.month))) month = DateTime(_first.year, _first.month); - if (month.isAfter(_last)) month = DateTime(_last.year, _last.month); + + if (month.isBefore(DateTime(_first.year, _first.month))) { + month = DateTime(_first.year, _first.month); + } + + if (month.isAfter(_last)) { + month = DateTime(_last.year, _last.month); + } + _month = month; _years = false; }), @@ -248,6 +283,7 @@ class _StaticDatePickerState extends State { final offset = (DateTime(_month.year, _month.month).weekday % 7 - labels.firstDayOfWeekIndex + 7) % 7; final count = DateUtils.getDaysInMonth(_month.year, _month.month); final colors = Theme.of(context).colorScheme; + return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Table( @@ -265,26 +301,31 @@ class _StaticDatePickerState extends State { TableRow( children: List.generate(7, (column) { final day = row * 7 + column - offset + 1; - if (day < 1 || day > count) return const SizedBox(height: 40); + + if (day < 1 || day > count) { + return const SizedBox(height: 40); + } + final date = DateTime(_month.year, _month.month, day); final selected = DateUtils.isSameDay(date, _start) || DateUtils.isSameDay(date, _end); final inRange = _start != null && _end != null && date.isAfter(_start!) && date.isBefore(_end!); + return SizedBox( height: 40, child: TextButton( style: TextButton.styleFrom( minimumSize: Size.zero, padding: EdgeInsets.zero, - backgroundColor: selected - ? colors.primary - : inRange - ? colors.primaryContainer - : null, - foregroundColor: selected - ? colors.onPrimary - : inRange - ? colors.onPrimaryContainer - : colors.onSurface, + backgroundColor: switch (selected) { + true => colors.primary, + false when inRange => colors.primaryContainer, + false => null, + }, + foregroundColor: switch (selected) { + true => colors.onPrimary, + false when inRange => colors.onPrimaryContainer, + false => colors.onSurface, + }, ), onPressed: date.isBefore(_first) || date.isAfter(_last) ? null : () => _select(date), child: Text(labels.formatDecimal(day)), diff --git a/app/lib/widgets/shell/mobile_bottom_nav.dart b/app/lib/widgets/shell/mobile_bottom_nav.dart index d675985c..01ba1d38 100644 --- a/app/lib/widgets/shell/mobile_bottom_nav.dart +++ b/app/lib/widgets/shell/mobile_bottom_nav.dart @@ -23,6 +23,7 @@ class MobileBottomNav extends StatelessWidget { onDestinationSelected: (index) { final item = mainItems[index]; // For library, navigate to books subpage + if (item.path == '/library') { onNavigate('/library/books'); } else { @@ -45,6 +46,7 @@ class MobileBottomNav extends StatelessWidget { return i; } } + return 0; } } diff --git a/app/lib/widgets/shell/nav_item_count.dart b/app/lib/widgets/shell/nav_item_count.dart index f637a96b..080ad763 100644 --- a/app/lib/widgets/shell/nav_item_count.dart +++ b/app/lib/widgets/shell/nav_item_count.dart @@ -12,6 +12,7 @@ class NavItemCount extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final foreground = selected ? colors.onPrimaryContainer : colors.onSurfaceVariant; + return Container( padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: 2), decoration: BoxDecoration( diff --git a/app/lib/widgets/shelves/add_shelf_sheet.dart b/app/lib/widgets/shelves/add_shelf_sheet.dart index 1801d7b1..8924b38b 100644 --- a/app/lib/widgets/shelves/add_shelf_sheet.dart +++ b/app/lib/widgets/shelves/add_shelf_sheet.dart @@ -67,6 +67,7 @@ class _AddShelfSheetState extends State with PersistentSave Navigator.of(context).pop(), @@ -261,7 +262,10 @@ class _AddShelfSheetState extends State with PersistentSave _onSave() async { final name = _nameController.text.trim(); - if (name.isEmpty) return; + + if (name.isEmpty) { + return; + } final saved = await persist( () => widget.onSave?.call( @@ -271,6 +275,9 @@ class _AddShelfSheetState extends State with PersistentSave with PersistentSave void initState() { super.initState(); _repository = context.read().libraryRepository?.shelves; + if (widget.isBulkMode) { _selectedShelfIds = {}; } else { @@ -103,6 +104,7 @@ class _MoveToShelfSheetState extends State with PersistentSave final filtered = shelves.where((item) => item.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); final mobile = MediaQuery.sizeOf(context).width < Breakpoints.tablet; final shortViewport = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom < 400; + Widget buildSheet(ScrollController? scrollController) => AppBottomSheet( avoidKeyboard: false, expandBody: !mobile && shelves.isNotEmpty, @@ -194,6 +196,7 @@ class _MoveToShelfSheetState extends State with PersistentSave ], ), ); + return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: mobile || shelves.isEmpty @@ -213,11 +216,16 @@ class _MoveToShelfSheetState extends State with PersistentSave Widget _buildCover(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final book = widget.book; + final placeholder = Container( color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); - if (book == null) return placeholder; + + if (book == null) { + return placeholder; + } + return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } @@ -225,6 +233,7 @@ class _MoveToShelfSheetState extends State with PersistentSave final compact = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom < 400 || MediaQuery.textScalerOf(context).scale(16) > 20; + return SizedBox( width: double.infinity, child: EmptyState( @@ -322,6 +331,7 @@ class _MoveToShelfSheetState extends State with PersistentSave context, onSave: (name, description, colorHex, icon) async { final now = DateTime.now(); + final newShelf = Shelf( id: const Uuid().v4(), name: name, @@ -332,8 +342,12 @@ class _MoveToShelfSheetState extends State with PersistentSave createdAt: now, updatedAt: now, ); + await dataStore.addShelf(newShelf, repository: repository); - if (!mounted) return; + + if (!mounted) { + return; + } // Auto-select the newly created shelf setState(() { @@ -345,6 +359,9 @@ class _MoveToShelfSheetState extends State with PersistentSave Future _onSave() async { final saved = await persist(() => widget.onSave?.call(_selectedShelfIds.toList())); - if (saved && mounted) Navigator.pop(context); + + if (saved && mounted) { + Navigator.pop(context); + } } } diff --git a/app/lib/widgets/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index c7ecb77e..76529b9c 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -50,10 +50,6 @@ class _ShelfCardState extends State { ); } - // ============================================================================ - // GRID CARD - // ============================================================================ - Widget _buildGridCard(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -236,6 +232,7 @@ class _ShelfCardState extends State { builder: (context, constraints) { final showTitle = constraints.maxWidth >= 120 && constraints.maxHeight >= 160; final iconSize = (constraints.biggest.shortestSide * 0.5).clamp(0.0, IconSizes.display); + return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -277,10 +274,6 @@ class _ShelfCardState extends State { ); } - // ============================================================================ - // LIST ITEM - // ============================================================================ - Widget _buildListItem(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; diff --git a/app/lib/widgets/shelves/shelves_filter_chips.dart b/app/lib/widgets/shelves/shelves_filter_chips.dart index 259dd8ef..c67dea57 100644 --- a/app/lib/widgets/shelves/shelves_filter_chips.dart +++ b/app/lib/widgets/shelves/shelves_filter_chips.dart @@ -12,6 +12,7 @@ typedef _ShelfSortSelection = ({ShelfSortOption option, bool ascending}); bool _isEinkTheme(ThemeData theme) { final border = theme.inputDecorationTheme.border; + return border is OutlineInputBorder && border.borderRadius == BorderRadius.zero && border.borderSide.width >= BorderWidths.einkDefault; @@ -272,9 +273,9 @@ class ShelvesFilterChips extends StatelessWidget { ), ]; - chips.sort((a, b) { - final activeComparison = (b.isActive ? 1 : 0).compareTo(a.isActive ? 1 : 0); - return activeComparison != 0 ? activeComparison : a.defaultOrder.compareTo(b.defaultOrder); + chips.sort((left, right) { + final activeComparison = (right.isActive ? 1 : 0).compareTo(left.isActive ? 1 : 0); + return activeComparison != 0 ? activeComparison : left.defaultOrder.compareTo(right.defaultOrder); }); final orderKey = [...chips.map((chip) => chip.id), if (provider.hasActiveShelfControls) 'clear-all'].join('-'); @@ -288,6 +289,7 @@ class ShelvesFilterChips extends StatelessWidget { switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { final offsetAnimation = Tween(begin: const Offset(0.03, 0), end: Offset.zero).animate(animation); + return FadeTransition( opacity: animation, child: SlideTransition(position: offsetAnimation, child: child), diff --git a/app/lib/widgets/statistics/reading_charts.dart b/app/lib/widgets/statistics/reading_charts.dart index 67c18295..af02018f 100644 --- a/app/lib/widgets/statistics/reading_charts.dart +++ b/app/lib/widgets/statistics/reading_charts.dart @@ -8,10 +8,6 @@ import 'package:papyrus/providers/statistics_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/themes/app_motion.dart'; -// ============================================================================= -// DATA AGGREGATION -// ============================================================================= - /// A bucket of aggregated daily activities (e.g., a week). class _AggregatedBucket { final DateTime startDate; @@ -38,33 +34,45 @@ class _AggregatedBucket { if (startDate.month == endDate.month) { return '${_months[startDate.month - 1]} ${startDate.day}-${endDate.day}'; } + return '${_months[startDate.month - 1]} ${startDate.day} - ${_months[endDate.month - 1]} ${endDate.day}'; } } /// Aggregates daily activities into weekly buckets. List<_AggregatedBucket> _aggregateWeekly(List activities) { - if (activities.isEmpty) return []; + if (activities.isEmpty) { + return []; + } final buckets = <_AggregatedBucket>[]; var bucketStart = 0; while (bucketStart < activities.length) { var bucketEnd = bucketStart; + while (bucketEnd + 1 < activities.length) { final nextDate = activities[bucketEnd + 1].date; - if (nextDate.weekday == DateTime.monday && bucketEnd > bucketStart) break; - if (bucketEnd - bucketStart >= 6) break; + + if (nextDate.weekday == DateTime.monday && bucketEnd > bucketStart) { + break; + } + + if (bucketEnd - bucketStart >= 6) { + break; + } + bucketEnd++; } final slice = activities.sublist(bucketStart, bucketEnd + 1); + buckets.add( _AggregatedBucket( startDate: slice.first.date, endDate: slice.last.date, - totalMinutes: slice.fold(0, (s, a) => s + a.readingMinutes), - totalPages: slice.fold(0, (s, a) => s + a.pagesRead), + totalMinutes: slice.fold(0, (total, item) => total + item.readingMinutes), + totalPages: slice.fold(0, (total, item) => total + item.pagesRead), dayCount: slice.length, ), ); @@ -75,39 +83,53 @@ List<_AggregatedBucket> _aggregateWeekly(List activities) { return buckets; } -// ============================================================================= -// AXIS HELPERS -// ============================================================================= - const _months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /// Formats a minutes value for the Y-axis (e.g., "0", "30m", "1h", "1.5h"). String _formatMinutesLabel(double value) { - if (value <= 0) return '0'; - if (value < 60) return '${value.round()}m'; + if (value <= 0) { + return '0'; + } + + if (value < 60) { + return '${value.round()}m'; + } + final hours = value / 60; - if (hours == hours.truncateToDouble()) return '${hours.toInt()}h'; + + if (hours == hours.truncateToDouble()) { + return '${hours.toInt()}h'; + } + return '${hours.toStringAsFixed(1)}h'; } /// Formats a pages value for the Y-axis. String _formatPagesLabel(double value) { - if (value <= 0) return '0'; + if (value <= 0) { + return '0'; + } + return '${value.round()}'; } /// Computes a nice Y-axis interval and maxY that align cleanly. ({double interval, double maxY}) _niceYAxis(double maxValue) { - if (maxValue <= 0) return (interval: 15.0, maxY: 60.0); + if (maxValue <= 0) { + return (interval: 15.0, maxY: 60.0); + } + final rawInterval = maxValue / 3; const niceSteps = [5, 10, 15, 20, 25, 30, 50, 60, 100, 120, 150, 200, 250, 300, 500, 1000]; var interval = (rawInterval / 100).ceil() * 100.0; + for (final step in niceSteps) { if (step >= rawInterval) { interval = step.toDouble(); break; } } + final maxY = (maxValue / interval).ceil() * interval; return (interval: interval, maxY: maxY); } @@ -126,11 +148,13 @@ Widget _buildYAxisLabel(double value, TextTheme textTheme, ColorScheme colorSche /// Builds a bottom axis label for chart data points. Widget _buildBottomLabel(double value, List activities, TextTheme textTheme, ColorScheme colorScheme) { final idx = value.toInt(); - if (idx < 0 || idx >= activities.length) return const SizedBox.shrink(); + + if (idx < 0 || idx >= activities.length) { + return const SizedBox.shrink(); + } final activity = activities[idx]; final count = activities.length; - String? label; if (count <= 7) { @@ -138,6 +162,7 @@ Widget _buildBottomLabel(double value, List activities, TextTheme } else if (count <= 31) { final day = activity.date.day; final interval = count > 14 ? 5 : 3; + if (day == 1 || day % interval == 0 || idx == count - 1) { label = '${activity.date.day}'; } @@ -147,7 +172,9 @@ Widget _buildBottomLabel(double value, List activities, TextTheme } } - if (label == null) return const SizedBox.shrink(); + if (label == null) { + return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.only(top: 8), @@ -170,9 +197,13 @@ Widget _buildBucketBottomLabel( ColorScheme colorScheme, ) { final idx = value.toInt(); - if (idx < 0 || idx >= buckets.length) return const SizedBox.shrink(); + + if (idx < 0 || idx >= buckets.length) { + return const SizedBox.shrink(); + } final bucket = buckets[idx]; + if (idx > 0 && buckets[idx].startDate.month == buckets[idx - 1].startDate.month) { return const SizedBox.shrink(); } @@ -190,10 +221,6 @@ Widget _buildBucketBottomLabel( ); } -// ============================================================================= -// READING TIME BAR CHART -// ============================================================================= - /// A bar chart displaying reading time data using fl_chart. class ReadingTimeBarChart extends StatelessWidget { final List activities; @@ -204,7 +231,9 @@ class ReadingTimeBarChart extends StatelessWidget { @override Widget build(BuildContext context) { - if (activities.isEmpty) return _buildEmptyState(context); + if (activities.isEmpty) { + return _buildEmptyState(context); + } final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -213,6 +242,7 @@ class ReadingTimeBarChart extends StatelessWidget { if (activities.length > 21) { return _buildAggregatedChart(context, colorScheme, textTheme, chartHeight); } + return _buildDailyChart(context, colorScheme, textTheme, chartHeight); } @@ -239,6 +269,7 @@ class ReadingTimeBarChart extends StatelessWidget { tooltipRoundedRadius: AppRadius.md, getTooltipItem: (group, groupIndex, rod, rodIndex) { final activity = activities[groupIndex]; + return BarTooltipItem( '${activity.dayName}\n${activity.readingTimeLabel}', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), @@ -278,6 +309,7 @@ class ReadingTimeBarChart extends StatelessWidget { barGroups: activities.asMap().entries.map((entry) { final index = entry.key; final activity = entry.value; + return BarChartGroupData( x: index, barRods: [ @@ -300,9 +332,16 @@ class ReadingTimeBarChart extends StatelessWidget { Widget _buildAggregatedChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { final buckets = _aggregateWeekly(activities); - if (buckets.isEmpty) return _buildEmptyState(context); - final maxMinutes = buckets.map((b) => b.totalMinutes).reduce((a, b) => math.max(a, b)).toDouble(); + if (buckets.isEmpty) { + return _buildEmptyState(context); + } + + final maxMinutes = buckets + .map((item) => item.totalMinutes) + .reduce((total, value) => math.max(total, value)) + .toDouble(); + final yAxis = _niceYAxis(maxMinutes); return SizedBox( @@ -326,6 +365,7 @@ class ReadingTimeBarChart extends StatelessWidget { final bucket = buckets[groupIndex]; final hours = bucket.totalMinutes / 60; final timeLabel = hours >= 1 ? '${hours.toStringAsFixed(1)}h' : '${bucket.totalMinutes}m'; + return BarTooltipItem( '${bucket.label}\n$timeLabel total', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), @@ -365,6 +405,7 @@ class ReadingTimeBarChart extends StatelessWidget { barGroups: buckets.asMap().entries.map((entry) { final index = entry.key; final bucket = entry.value; + return BarChartGroupData( x: index, barRods: [ @@ -393,10 +434,6 @@ class ReadingTimeBarChart extends StatelessWidget { } } -// ============================================================================= -// PAGES READ LINE CHART -// ============================================================================= - /// A line chart displaying pages read trends using fl_chart. class PagesReadLineChart extends StatelessWidget { final List activities; @@ -407,7 +444,9 @@ class PagesReadLineChart extends StatelessWidget { @override Widget build(BuildContext context) { - if (activities.isEmpty) return _buildEmptyState(context); + if (activities.isEmpty) { + return _buildEmptyState(context); + } final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -416,11 +455,16 @@ class PagesReadLineChart extends StatelessWidget { if (activities.length > 21) { return _buildAggregatedChart(context, colorScheme, textTheme, chartHeight); } + return _buildDailyChart(context, colorScheme, textTheme, chartHeight); } Widget _buildDailyChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { - final maxPages = activities.map((a) => a.pagesRead).reduce((a, b) => math.max(a, b)).toDouble(); + final maxPages = activities + .map((item) => item.pagesRead) + .reduce((total, value) => math.max(total, value)) + .toDouble(); + final yAxis = _niceYAxis(maxPages); final spots = activities.asMap().entries.map((entry) { @@ -444,6 +488,7 @@ class PagesReadLineChart extends StatelessWidget { getTooltipItems: (touchedSpots) { return touchedSpots.map((spot) { final activity = activities[spot.x.toInt()]; + return LineTooltipItem( '${activity.dayName}\n${activity.pagesRead} pages', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), @@ -511,9 +556,12 @@ class PagesReadLineChart extends StatelessWidget { Widget _buildAggregatedChart(BuildContext context, ColorScheme colorScheme, TextTheme textTheme, double chartHeight) { final buckets = _aggregateWeekly(activities); - if (buckets.isEmpty) return _buildEmptyState(context); - final maxPages = buckets.map((b) => b.totalPages).reduce((a, b) => math.max(a, b)).toDouble(); + if (buckets.isEmpty) { + return _buildEmptyState(context); + } + + final maxPages = buckets.map((item) => item.totalPages).reduce((total, value) => math.max(total, value)).toDouble(); final yAxis = _niceYAxis(maxPages); final spots = buckets.asMap().entries.map((entry) { @@ -537,6 +585,7 @@ class PagesReadLineChart extends StatelessWidget { getTooltipItems: (touchedSpots) { return touchedSpots.map((spot) { final bucket = buckets[spot.x.toInt()]; + return LineTooltipItem( '${bucket.label}\n${bucket.totalPages} pages', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), @@ -610,10 +659,6 @@ class PagesReadLineChart extends StatelessWidget { } } -// ============================================================================= -// BOOKS PER MONTH CHART -// ============================================================================= - /// A bar chart displaying books read per month using fl_chart. class BooksPerMonthChart extends StatelessWidget { final List monthlyStats; @@ -623,11 +668,18 @@ class BooksPerMonthChart extends StatelessWidget { @override Widget build(BuildContext context) { - if (monthlyStats.isEmpty) return _buildEmptyState(context); + if (monthlyStats.isEmpty) { + return _buildEmptyState(context); + } final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final maxBooks = monthlyStats.map((m) => m.booksRead).reduce((a, b) => math.max(a, b)).toDouble(); + + final maxBooks = monthlyStats + .map((item) => item.booksRead) + .reduce((total, value) => math.max(total, value)) + .toDouble(); + final chartHeight = isDesktop ? 200.0 : 160.0; // Take last 6 months for display @@ -652,6 +704,7 @@ class BooksPerMonthChart extends StatelessWidget { tooltipRoundedRadius: AppRadius.md, getTooltipItem: (group, groupIndex, rod, rodIndex) { final stats = displayStats[groupIndex]; + return BarTooltipItem( '${stats.fullMonthLabel}\n${stats.booksRead} books', textTheme.bodySmall!.copyWith(color: colorScheme.onInverseSurface), @@ -670,7 +723,9 @@ class BooksPerMonthChart extends StatelessWidget { if (value < 0 || value >= displayStats.length) { return const SizedBox.shrink(); } + final stats = displayStats[value.toInt()]; + return Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -697,6 +752,7 @@ class BooksPerMonthChart extends StatelessWidget { ), ); } + return const SizedBox.shrink(); }, ), @@ -713,6 +769,7 @@ class BooksPerMonthChart extends StatelessWidget { barGroups: displayStats.asMap().entries.map((entry) { final index = entry.key; final stats = entry.value; + return BarChartGroupData( x: index, barRods: [ diff --git a/app/lib/widgets/statistics/stat_card.dart b/app/lib/widgets/statistics/stat_card.dart index af513125..932826ed 100644 --- a/app/lib/widgets/statistics/stat_card.dart +++ b/app/lib/widgets/statistics/stat_card.dart @@ -91,7 +91,6 @@ class StatCard extends StatelessWidget { Widget _buildTrendBadge(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final isPositive = isTrendPositive ?? true; final trendColor = isPositive ? colorScheme.tertiary : colorScheme.error; diff --git a/app/lib/widgets/topics/add_topic_sheet.dart b/app/lib/widgets/topics/add_topic_sheet.dart index e3475435..7574534d 100644 --- a/app/lib/widgets/topics/add_topic_sheet.dart +++ b/app/lib/widgets/topics/add_topic_sheet.dart @@ -64,6 +64,7 @@ class _AddTopicSheetState extends State with PersistentSave Navigator.of(context).pop(), @@ -218,7 +219,10 @@ class _AddTopicSheetState extends State with PersistentSave _onSave() async { final name = _nameController.text.trim(); - if (name.isEmpty) return; + + if (name.isEmpty) { + return; + } final saved = await persist( () => widget.onSave?.call( @@ -227,6 +231,9 @@ class _AddTopicSheetState extends State with PersistentSave with PersistentSa void initState() { super.initState(); _repository = context.read().libraryRepository?.tags; + if (widget.isBulkMode) { _selectedTagIds = {}; } else { @@ -103,6 +104,7 @@ class _ManageTopicsSheetState extends State with PersistentSa final filtered = tags.where((item) => item.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); final mobile = MediaQuery.sizeOf(context).width < Breakpoints.tablet; final shortViewport = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom < 400; + Widget buildSheet(ScrollController? scrollController) => AppBottomSheet( avoidKeyboard: false, expandBody: !mobile && tags.isNotEmpty, @@ -192,6 +194,7 @@ class _ManageTopicsSheetState extends State with PersistentSa ], ), ); + return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: mobile || tags.isEmpty @@ -211,11 +214,16 @@ class _ManageTopicsSheetState extends State with PersistentSa Widget _buildCover(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final book = widget.book; + final placeholder = Container( color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); - if (book == null) return placeholder; + + if (book == null) { + return placeholder; + } + return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } @@ -223,6 +231,7 @@ class _ManageTopicsSheetState extends State with PersistentSa final compact = MediaQuery.sizeOf(context).height - MediaQuery.viewInsetsOf(context).bottom < 400 || MediaQuery.textScalerOf(context).scale(16) > 20; + return SizedBox( width: double.infinity, child: EmptyState( @@ -328,6 +337,7 @@ class _ManageTopicsSheetState extends State with PersistentSa context, onSave: (name, description, colorHex) async { final now = DateTime.now(); + final newTag = Tag( id: const Uuid().v4(), name: name, @@ -335,8 +345,12 @@ class _ManageTopicsSheetState extends State with PersistentSa description: description, createdAt: now, ); + await dataStore.addTag(newTag, repository: repository); - if (!mounted) return; + + if (!mounted) { + return; + } // Auto-select the newly created topic setState(() { @@ -348,6 +362,9 @@ class _ManageTopicsSheetState extends State with PersistentSa Future _onSave() async { final saved = await persist(() => widget.onSave?.call(_selectedTagIds.toList())); - if (saved && mounted) Navigator.pop(context); + + if (saved && mounted) { + Navigator.pop(context); + } } } diff --git a/app/lib/widgets/topics/topic_detail_sheet.dart b/app/lib/widgets/topics/topic_detail_sheet.dart index 5660b532..b2febd4e 100644 --- a/app/lib/widgets/topics/topic_detail_sheet.dart +++ b/app/lib/widgets/topics/topic_detail_sheet.dart @@ -34,6 +34,7 @@ class TopicDetailSheet extends StatelessWidget { final textTheme = Theme.of(context).textTheme; final dataStore = context.watch(); final bookCount = dataStore.getBookCountForTag(tag.id); + return AppBottomSheet( header: Row( children: [ @@ -139,7 +140,10 @@ class TopicDetailSheet extends StatelessWidget { onPressed: () async { try { await dataStore.deleteTag(tag.id, repository: repository); - if (dialogContext.mounted) Navigator.pop(dialogContext); + + if (dialogContext.mounted) { + Navigator.pop(dialogContext); + } } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/app/test/acquisition/acquisition_api_client_test.dart b/app/test/acquisition/acquisition_api_client_test.dart index bc0ca063..0ccccbdd 100644 --- a/app/test/acquisition/acquisition_api_client_test.dart +++ b/app/test/acquisition/acquisition_api_client_test.dart @@ -10,11 +10,13 @@ import 'package:papyrus/auth/papyrus_api_config.dart'; void main() { test('uses acquisition capabilities endpoint with bearer auth', () async { final seenPaths = []; + final client = AcquisitionApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), httpClient: MockClient((request) async { seenPaths.add(request.url.path); expect(request.headers['authorization'], 'Bearer access-token'); + return http.Response( jsonEncode({ 'enabled': true, @@ -32,7 +34,6 @@ void main() { ); final capabilities = await client.capabilities('access-token'); - expect(seenPaths, ['/v1/acquisition/capabilities']); expect(capabilities.downloadClientKinds, [AcquisitionEndpointKind.qbittorrent]); expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], ['BookSearch']); @@ -40,10 +41,12 @@ void main() { test('sends the qBittorrent download root when creating and updating endpoints', () async { final requestBodies = >[]; + final client = AcquisitionApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), httpClient: MockClient((request) async { requestBodies.add(jsonDecode(request.body) as Map); + return http.Response( jsonEncode({ 'endpoint_id': 'client-1', @@ -65,19 +68,21 @@ void main() { baseUrl: Uri.parse('http://qbittorrent.local:8082'), downloadRoot: '/downloads', ); - await client.updateEndpoint(accessToken: 'access-token', endpointId: 'client-1', downloadRoot: '/new-downloads'); + await client.updateEndpoint(accessToken: 'access-token', endpointId: 'client-1', downloadRoot: '/new-downloads'); expect(requestBodies[0]['download_root'], '/downloads'); expect(requestBodies[1], {'download_root': '/new-downloads'}); }); test('submits release tokens as a partial-success batch', () async { late Map requestBody; + final client = AcquisitionApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), httpClient: MockClient((request) async { expect(request.url.path, '/v1/acquisition/submissions/batch'); requestBody = jsonDecode(request.body) as Map; + return http.Response( jsonEncode({ 'items': [ @@ -107,6 +112,7 @@ void main() { test('uses paginated job lifecycle request shapes', () async { final requests = []; + final client = AcquisitionApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), httpClient: MockClient((request) async { @@ -127,6 +133,7 @@ void main() { 200, ); } + if (request.method == 'GET' && request.url.path == '/v1/acquisition/jobs') { return http.Response( jsonEncode({ @@ -138,6 +145,7 @@ void main() { 200, ); } + if (request.method == 'DELETE') { return http.Response('', 204); } @@ -153,7 +161,6 @@ void main() { await client.cancelJob(accessToken: 'access-token', jobId: 'job-1'); await client.retryJobImport(accessToken: 'access-token', jobId: 'job-1'); await client.removeJob(accessToken: 'access-token', jobId: 'job-1'); - expect(page.total, 1); expect(files.single.name, 'Example.epub'); expect(requests[0].url.queryParameters, {'limit': '25', 'offset': '5'}); @@ -166,6 +173,7 @@ void main() { test('tests an unsaved endpoint without sending irrelevant credentials', () async { late Map requestBody; + final client = AcquisitionApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), httpClient: MockClient((request) async { @@ -187,6 +195,7 @@ void main() { test('tests an edited endpoint with only supplied overrides', () async { late Map requestBody; + final client = AcquisitionApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test')), httpClient: MockClient((request) async { diff --git a/app/test/acquisition/acquisition_availability_provider_test.dart b/app/test/acquisition/acquisition_availability_provider_test.dart index 52e0c2c1..bd6e48dd 100644 --- a/app/test/acquisition/acquisition_availability_provider_test.dart +++ b/app/test/acquisition/acquisition_availability_provider_test.dart @@ -20,9 +20,11 @@ void main() { test('loads and caches enabled capability state by server', () async { final calls = []; + final provider = AcquisitionAvailabilityProvider( loadCapabilities: (serverBaseUri) async { calls.add(serverBaseUri); + return const AcquisitionCapabilities( enabled: true, endpointKinds: [], @@ -33,11 +35,10 @@ void main() { ); }, ); - final server = Uri.parse('https://api.test'); + final server = Uri.parse('https://api.test'); await provider.refresh(server); await provider.refresh(server); - expect(provider.state, AcquisitionAvailabilityState.available); expect(provider.isAvailableFor(server), isTrue); expect(provider.managedDownloadsReadyFor(server), isTrue); @@ -55,12 +56,10 @@ void main() { arrCommands: {}, ), ); - final server = Uri.parse('https://api.test'); + final server = Uri.parse('https://api.test'); expect(provider.isAvailableFor(server), isFalse); - await provider.refresh(server); - expect(provider.state, AcquisitionAvailabilityState.unavailable); expect(provider.isAvailableFor(server), isFalse); expect(provider.managedDownloadsReadyFor(server), isFalse); @@ -76,9 +75,11 @@ void main() { final replacementServer = Uri.parse('https://replacement.test'); var activeConfig = PapyrusApiConfig(serverBaseUri: initialServer); final loadedServers = []; + final availabilityProvider = AcquisitionAvailabilityProvider( loadCapabilities: (serverBaseUri) async { loadedServers.add(serverBaseUri); + return AcquisitionCapabilities( enabled: true, managedDownloadsReady: serverBaseUri == initialServer, @@ -90,9 +91,11 @@ void main() { ); }, ); + final downloadsProvider = AcquisitionDownloadsProvider(pollingInterval: Duration.zero); final gateways = <_FakeDownloadsGateway>[]; final gatewayConfigs = []; + final composition = AcquisitionDownloadsComposition( authProvider: authProvider, availabilityProvider: availabilityProvider, @@ -109,8 +112,8 @@ void main() { await authProvider.bootstrap(); await tester.pump(); await tester.pump(); - AcquisitionDownloadsProvider? suppliedProvider; + await tester.pumpWidget( MultiProvider( providers: [composition.providerRegistration()], @@ -129,57 +132,40 @@ void main() { expect(loadedServers, [initialServer]); expect(gateways, hasLength(1)); expect(gatewayConfigs.single.serverBaseUri, initialServer); - activeConfig = PapyrusApiConfig(serverBaseUri: replacementServer); composition.handleServerChanged(); - expect(downloadsProvider.isConfigured, isFalse); expect(gateways.single.closed, isTrue); - final replacementRepository = _FakeAuthRepository()..bootstrapResult = _tokens(); await authProvider.replaceRepository(replacementRepository); await tester.pump(); await tester.pump(); - expect(downloadsProvider.isConfigured, isTrue); expect(downloadsProvider.isManagedAcquisitionReady, isFalse); expect(loadedServers, [initialServer, replacementServer]); expect(gateways, hasLength(2)); expect(gatewayConfigs.last.serverBaseUri, replacementServer); - await authProvider.signOut(); - expect(downloadsProvider.isConfigured, isFalse); expect(gateways.last.closed, isTrue); - await authProvider.login(email: 'reader@example.com', password: 'secret'); await tester.pump(); await tester.pump(); - expect(downloadsProvider.isConfigured, isTrue); expect(gateways, hasLength(3)); - authProvider.setOfflineMode(true); - expect(downloadsProvider.isConfigured, isFalse); expect(gateways.last.closed, isTrue); - await authProvider.login(email: 'reader@example.com', password: 'secret'); await tester.pump(); await tester.pump(); - expect(downloadsProvider.isConfigured, isTrue); expect(gateways, hasLength(4)); - await tester.pumpWidget(const SizedBox()); composition.dispose(); - expect(gateways.last.closed, isTrue); - await authProvider.signOut(); - expect(gateways, hasLength(4)); - availabilityProvider.dispose(); authProvider.dispose(); }); diff --git a/app/test/acquisition/acquisition_downloads_provider_test.dart b/app/test/acquisition/acquisition_downloads_provider_test.dart index a52c063d..b09f1f2f 100644 --- a/app/test/acquisition/acquisition_downloads_provider_test.dart +++ b/app/test/acquisition/acquisition_downloads_provider_test.dart @@ -27,13 +27,11 @@ void main() { ), ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshConfiguration(); - expect(provider.isManagedAcquisitionReady, isTrue); expect(provider.downloadClients.single.id, 'client-1'); - provider.dispose(); }); @@ -44,30 +42,27 @@ void main() { AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.completed)], total: 1, limit: 50, offset: 0), ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-1'); - expect(provider.jobs.single.status, AcquisitionJobStatus.downloading); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.downloading); expect(provider.jobById('missing'), isNull); expect(provider.jobsByBookId['book-1']?.status, AcquisitionJobStatus.downloading); expect(provider.activeCount, 1); expect(provider.selectedJobIds, {'job-1'}); - await provider.refreshJobs(); - expect(provider.jobs.single.status, AcquisitionJobStatus.completed); expect(provider.jobsByBookId, isEmpty); expect(provider.activeCount, 0); expect(provider.selectedJobIds, {'job-1'}); - provider.dispose(); }); test('discards a stale refresh after removal and lets a later refresh converge', () async { final staleRefresh = Completer(); + final gateway = _FakeGateway( jobResponses: [ AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.failed)], total: 1, limit: 100, offset: 0), @@ -80,60 +75,56 @@ void main() { ), ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-1'); final refresh = provider.refreshJobs(); - await provider.removeSelectedJobs(); expect(provider.jobs, isEmpty); staleRefresh.complete( AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.failed)], total: 1, limit: 100, offset: 0), ); - await refresh; + await refresh; expect(provider.jobs, isEmpty); expect(provider.isLoadingJobs, isFalse); - await provider.refreshJobs(); - expect(provider.jobs.single.id, 'server-job'); expect(provider.isLoadingJobs, isFalse); - provider.dispose(); }); test('discards a stale refresh after successful cancellation', () async { final staleRefresh = Completer(); + final gateway = _FakeGateway( jobResponses: [ AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.downloading)], total: 1, limit: 100, offset: 0), staleRefresh.future, ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); final refresh = provider.refreshJobs(); - await provider.cancelJob('job-1'); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.cancelled); staleRefresh.complete( AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.downloading)], total: 1, limit: 100, offset: 0), ); - await refresh; + await refresh; expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.cancelled); expect(provider.isLoadingJobs, isFalse); - provider.dispose(); }); test('discards a stale refresh after successful retry', () async { final staleRefresh = Completer(); + final gateway = _FakeGateway( jobResponses: [ AcquisitionJobPage( @@ -145,11 +136,10 @@ void main() { staleRefresh.future, ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); final refresh = provider.refreshJobs(); - await provider.retryJobImport('job-1'); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.downloading); @@ -161,16 +151,16 @@ void main() { offset: 0, ), ); - await refresh; + await refresh; expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.downloading); expect(provider.isLoadingJobs, isFalse); - provider.dispose(); }); test('discards a stale refresh after successful submission inserts a job', () async { final staleRefresh = Completer(); + final gateway = _FakeGateway( jobResponses: [staleRefresh.future], batchResult: BatchSubmissionResponse( @@ -183,22 +173,18 @@ void main() { ], ), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); const release = TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'); - final refresh = provider.refreshJobs(); provider.setRemoteResults('query', const [release]); provider.toggleReleaseSelection('token-1'); - await provider.submitSelectedReleases('endpoint-1'); expect(provider.jobs.single.id, 'submitted-job'); - staleRefresh.complete(const AcquisitionJobPage(items: [], total: 0, limit: 100, offset: 0)); await refresh; - expect(provider.jobs.single.id, 'submitted-job'); expect(provider.isLoadingJobs, isFalse); - provider.dispose(); }); @@ -207,16 +193,14 @@ void main() { endpointsError: StateError('https://download-client.local/settings?token=secret'), searchError: const AuthApiException(statusCode: 503, message: 'indexer down'), ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshConfiguration(); await provider.searchRemote(' remote book '); - expect(provider.remoteQuery, 'remote book'); expect(provider.remoteResults, isEmpty); expect(provider.searchError, 'Could not search connected sources. Check the enabled indexers and try again.'); expect(provider.error, 'Could not load download settings. Try again.'); - provider.dispose(); }); @@ -227,35 +211,30 @@ void main() { ); await provider.searchRemote(' remote book '); - expect(provider.remoteQuery, 'remote book'); expect(provider.remoteResults, isEmpty); expect(provider.searchError, 'Could not search connected sources. Check the enabled indexers and try again.'); expect(provider.error, isNull); - provider.dispose(); }); test('clearing remote state invalidates a hung search without blocking the next search', () async { final oldSearch = Completer>(); + final gateway = _FakeGateway( searchResponses: [ oldSearch.future, const [TorrentRelease(title: 'New', releaseToken: 'new-token', protocol: 'torrent', indexer: 'Prowlarr')], ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); final oldOperation = provider.searchRemote('old'); expect(provider.isSearching, isTrue); - provider.clearRemoteResults(); - expect(provider.isSearching, isFalse); expect(provider.remoteQuery, isNull); - await provider.searchRemote('new'); - expect(provider.remoteQuery, 'new'); expect(provider.remoteResults.single.releaseToken, 'new-token'); expect(provider.isSearching, isFalse); @@ -263,12 +242,11 @@ void main() { oldSearch.complete(const [ TorrentRelease(title: 'Old', releaseToken: 'old-token', protocol: 'torrent', indexer: 'Prowlarr'), ]); - await oldOperation; + await oldOperation; expect(provider.remoteQuery, 'new'); expect(provider.remoteResults.single.releaseToken, 'new-token'); expect(provider.isSearching, isFalse); - provider.dispose(); }); @@ -279,9 +257,7 @@ void main() { ); await provider.refreshJobs(); - expect(provider.error, 'Could not refresh downloads. Try again.'); - provider.dispose(); }); @@ -300,17 +276,14 @@ void main() { expect(filesResult.files, isEmpty); expect(filesResult.error, 'Could not load download files. Try again.'); expect(provider.error, 'Could not load download files. Try again.'); - final selectFileOutcome = await provider.selectJobFile('job-1', 0); expect(selectFileOutcome.failed, isTrue); expect(selectFileOutcome.error, 'Could not select the download file. Try again.'); expect(provider.error, selectFileOutcome.error); - final retryOutcome = await provider.retryJobImport('job-1'); expect(retryOutcome.failed, isTrue); expect(retryOutcome.error, 'Could not retry the download import. Try again.'); expect(provider.error, retryOutcome.error); - provider.dispose(); }); @@ -324,10 +297,10 @@ void main() { ), pollingInterval: Duration.zero, ); + await cancelProvider.refreshJobs(); cancelProvider.toggleJobSelection('job-1'); final cancelOutcome = await cancelProvider.cancelSelectedJobs(); - expect(cancelOutcome.failed, isTrue); expect(cancelOutcome.error, 'Could not cancel the download. Try again.'); expect(cancelProvider.error, cancelOutcome.error); @@ -343,10 +316,10 @@ void main() { ), pollingInterval: Duration.zero, ); + await removeProvider.refreshJobs(); removeProvider.toggleJobSelection('job-1'); final removeOutcome = await removeProvider.removeSelectedJobs(); - expect(removeOutcome.failed, isTrue); expect(removeOutcome.error, 'Could not remove the download. Try again.'); expect(removeProvider.error, removeOutcome.error); @@ -369,23 +342,22 @@ void main() { ], retryImportErrorsByJobId: {'job-2': StateError('raw retry failure with token=secret')}, ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-1'); provider.toggleJobSelection('job-2'); - final outcome = await provider.retrySelectedJobs(); - expect(outcome.failed, isTrue); expect(outcome.error, 'Could not retry the download import. Try again.'); expect(provider.selectedJobIds, {'job-2'}); expect(gateway.retriedJobIds, ['job-1']); - provider.dispose(); }); test('bulk retry blocks concurrent remove and keeps ownership until its delayed result completes', () async { final retry = Completer(); + final gateway = _FakeGateway( jobPages: [ AcquisitionJobPage( @@ -397,88 +369,76 @@ void main() { ], retryImportCompleter: retry, ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-1'); - final retryOperation = provider.retrySelectedJobs(); - expect(provider.isMutatingJobs, isTrue); expect(gateway.retriedJobIds, ['job-1']); - final removeOutcome = await provider.removeSelectedJobs(); - expect(removeOutcome.ignored, isTrue); expect(gateway.removedJobIds, isEmpty); expect(provider.isMutatingJobs, isTrue); expect(provider.selectedJobIds, {'job-1'}); - retry.complete(_job(status: AcquisitionJobStatus.downloading)); final retryOutcome = await retryOperation; - expect(retryOutcome.succeeded, isTrue); expect(provider.isMutatingJobs, isFalse); expect(provider.selectedJobIds, isEmpty); - provider.dispose(); }); test('bulk removal ignores a duplicate invocation while the first request is delayed', () async { final removal = Completer(); + final gateway = _FakeGateway( jobPages: [ AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.failed)], total: 1, limit: 100, offset: 0), ], removeCompleter: removal, ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-1'); - final firstOperation = provider.removeSelectedJobs(); final duplicateOutcome = await provider.removeSelectedJobs(); - expect(duplicateOutcome.ignored, isTrue); expect(gateway.removedJobIds, ['job-1']); expect(provider.isMutatingJobs, isTrue); expect(provider.selectedJobIds, {'job-1'}); - removal.complete(); final firstOutcome = await firstOperation; - expect(firstOutcome.succeeded, isTrue); expect(provider.isMutatingJobs, isFalse); expect(provider.selectedJobIds, isEmpty); - provider.dispose(); }); test('bulk cancellation ignores a duplicate invocation while the first request is delayed', () async { final cancellation = Completer(); + final gateway = _FakeGateway( jobPages: [ AcquisitionJobPage(items: [_job(status: AcquisitionJobStatus.downloading)], total: 1, limit: 100, offset: 0), ], cancelCompleter: cancellation, ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-1'); - final firstOperation = provider.cancelSelectedJobs(); final duplicateOutcome = await provider.cancelSelectedJobs(); - expect(duplicateOutcome.ignored, isTrue); expect(gateway.cancelledJobIds, ['job-1']); expect(provider.isMutatingJobs, isTrue); - cancellation.complete(_job(status: AcquisitionJobStatus.cancelled)); final firstOutcome = await firstOperation; - expect(firstOutcome.succeeded, isTrue); expect(provider.isMutatingJobs, isFalse); expect(provider.selectedJobIds, isEmpty); - provider.dispose(); }); @@ -496,31 +456,25 @@ void main() { ), ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); provider.toggleJobSelection('job-2'); await provider.cancelJob('job-1'); - expect(gateway.cancelledJobIds, ['job-1']); expect(provider.jobs.where((job) => job.id == 'job-1').single.status, AcquisitionJobStatus.cancelled); expect(provider.selectedJobIds, {'job-2'}); - provider.dispose(); }); test('successful single cancellation clears a pre-existing provider error', () async { final gateway = _FakeGateway(filesError: StateError('https://download-client.local/files?token=secret')); final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); - await provider.listJobFiles('job-1'); expect(provider.error, 'Could not load download files. Try again.'); - await provider.cancelJob('job-1'); - expect(provider.error, isNull); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.cancelled); - provider.dispose(); }); @@ -533,7 +487,9 @@ void main() { ], ), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + const releases = [ TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'), TorrentRelease(title: 'Two', releaseToken: 'token-2', protocol: 'torrent', indexer: 'Prowlarr'), @@ -543,7 +499,6 @@ void main() { provider.toggleReleaseSelection('token-1'); provider.toggleReleaseSelection('token-2'); final outcome = await provider.submitSelectedReleases('endpoint-1'); - expect(gateway.submittedTokens, ['token-1', 'token-2']); expect(provider.jobs.single.status, AcquisitionJobStatus.submitted); expect(outcome.successfulCount, 1); @@ -553,7 +508,6 @@ void main() { expect(provider.submissionErrorsByReleaseToken, outcome.failuresByReleaseToken); expect(provider.submissionErrors, ['This release could not be sent to the download client.']); expect(provider.selectedReleaseTokens, {'token-2'}); - provider.dispose(); }); @@ -574,7 +528,9 @@ void main() { ], ), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + const releases = [ TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'), TorrentRelease(title: 'Two', releaseToken: 'token-2', protocol: 'torrent', indexer: 'Prowlarr'), @@ -583,13 +539,11 @@ void main() { provider.setRemoteResults('query', releases); provider.selectAllRemoteReleases(); final outcome = await provider.submitSelectedReleases('endpoint-1'); - expect(outcome.successfulCount, 2); expect(outcome.failuresByReleaseToken, isEmpty); expect(outcome.allSucceeded, isTrue); expect(provider.selectedReleaseTokens, isEmpty); expect(provider.jobs, hasLength(2)); - provider.dispose(); }); @@ -602,7 +556,9 @@ void main() { ], ), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + const releases = [ TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'), TorrentRelease(title: 'Two', releaseToken: 'token-2', protocol: 'torrent', indexer: 'Prowlarr'), @@ -611,13 +567,11 @@ void main() { provider.setRemoteResults('query', releases); provider.selectAllRemoteReleases(); final outcome = await provider.submitSelectedReleases('endpoint-1'); - expect(outcome.successfulCount, 0); expect(outcome.failedCount, 2); expect(outcome.allSucceeded, isFalse); expect(provider.selectedReleaseTokens, {'token-1', 'token-2'}); expect(provider.jobs, isEmpty); - provider.dispose(); }); @@ -638,14 +592,12 @@ void main() { ), ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await provider.refreshJobs(); - expect(provider.jobs.map((job) => job.id), containsAll(['job-new', 'job-active'])); expect(provider.activeCount, 1); expect(gateway.jobOffsets, [0, 1]); - provider.dispose(); }); @@ -657,19 +609,18 @@ void main() { const release = TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'); provider.setRemoteResults('query', const [release]); provider.toggleReleaseSelection('token-1'); - final operation = provider.submitSelectedReleases('endpoint-1'); provider.setGateway(secondGateway); + submission.complete( BatchSubmissionResponse( items: [BatchSubmissionItem(index: 0, job: _job(status: AcquisitionJobStatus.submitted), error: null)], ), ); - await operation; + await operation; expect(provider.jobs, isEmpty); expect(firstGateway.closed, isTrue); - provider.dispose(); }); @@ -678,7 +629,6 @@ void main() { final gateway = _FakeGateway(batchCompleter: submission); final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); const release = TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'); - provider.setRemoteResults('query', const [release]); provider.toggleReleaseSelection('token-1'); final operation = provider.submitSelectedReleases('endpoint-1'); @@ -687,11 +637,10 @@ void main() { submission.complete( const BatchSubmissionResponse(items: [BatchSubmissionItem(index: 0, job: null, error: 'rejected')]), ); - await operation; + await operation; expect(provider.selectedReleaseTokens, {'token-1'}); expect(provider.submissionErrorsByReleaseToken, {'token-1': 'The download client rejected this release.'}); - provider.dispose(); }); @@ -700,6 +649,7 @@ void main() { final search = Completer>(); final gateway = _FakeGateway(batchCompleter: submission, searchCompleter: search); final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + const oldReleases = [ TorrentRelease(title: 'Old one', releaseToken: 'old-1', protocol: 'torrent', indexer: 'Prowlarr'), TorrentRelease(title: 'Old two', releaseToken: 'old-2', protocol: 'torrent', indexer: 'Prowlarr'), @@ -713,6 +663,7 @@ void main() { search.complete(const [ TorrentRelease(title: 'New', releaseToken: 'new-1', protocol: 'torrent', indexer: 'Prowlarr'), ]); + await searchOperation; provider.toggleReleaseSelection('new-1'); @@ -724,14 +675,13 @@ void main() { ], ), ); - await submissionOperation; + await submissionOperation; expect(provider.remoteQuery, 'new'); expect(provider.remoteResults.single.releaseToken, 'new-1'); expect(provider.selectedReleaseTokens, {'new-1'}); expect(provider.submissionErrorsByReleaseToken, isEmpty); expect(provider.jobs, isEmpty); - provider.dispose(); }); @@ -744,21 +694,21 @@ void main() { provider.setRemoteResults('old', const [ TorrentRelease(title: 'Old', releaseToken: 'old-token', protocol: 'torrent', indexer: 'Prowlarr'), ]); + provider.toggleReleaseSelection('old-token'); final oldOperation = provider.submitSelectedReleases('endpoint-1'); - expect(provider.isSubmitting, isTrue); - provider.clearRemoteResults(); expect(provider.isSubmitting, isFalse); provider.setRemoteResults('new', const [ TorrentRelease(title: 'New', releaseToken: 'new-token', protocol: 'torrent', indexer: 'Prowlarr'), ]); + provider.toggleReleaseSelection('new-token'); final newOperation = provider.submitSelectedReleases('endpoint-1'); - expect(provider.isSubmitting, isTrue); + expect(gateway.submittedTokenBatches, [ ['old-token'], ['new-token'], @@ -775,8 +725,8 @@ void main() { ], ), ); - await oldOperation; + await oldOperation; expect(provider.isSubmitting, isTrue); expect(provider.remoteQuery, 'new'); expect(provider.selectedReleaseTokens, {'new-token'}); @@ -793,12 +743,11 @@ void main() { ], ), ); - await newOperation; + await newOperation; expect(provider.isSubmitting, isFalse); expect(provider.selectedReleaseTokens, isEmpty); expect(provider.jobs.map((job) => job.id), ['new-job']); - provider.dispose(); }); @@ -812,7 +761,9 @@ void main() { ], ), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + const releases = [ TorrentRelease(title: 'One', releaseToken: 'token-1', protocol: 'torrent', indexer: 'Prowlarr'), TorrentRelease(title: 'Two', releaseToken: 'token-2', protocol: 'torrent', indexer: 'Prowlarr'), @@ -822,32 +773,32 @@ void main() { provider.setRemoteResults('query', releases); provider.selectAllRemoteReleases(); final outcome = await provider.submitSelectedReleases('endpoint-1'); - expect(outcome.successfulCount, 1); + expect(outcome.failuresByReleaseToken, { 'token-2': 'The download client did not return a result for this release.', 'token-3': 'The download client did not return a result for this release.', }); + expect(provider.selectedReleaseTokens, {'token-2', 'token-3'}); expect(provider.jobs, hasLength(1)); - provider.dispose(); }); test('clears stale row errors when a new search starts', () async { final search = Completer>(); + final gateway = _FakeGateway( batchResult: const BatchSubmissionResponse(items: [BatchSubmissionItem(index: 0, job: null, error: 'rejected')]), searchCompleter: search, ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); const release = TorrentRelease(title: 'Old', releaseToken: 'token-old', protocol: 'torrent', indexer: 'Prowlarr'); - provider.setRemoteResults('old', const [release]); provider.toggleReleaseSelection('token-old'); await provider.submitSelectedReleases('endpoint-1'); final operation = provider.searchRemote(' new '); - expect(provider.remoteQuery, 'new'); expect(provider.remoteResults, isEmpty); expect(provider.submissionErrorsByReleaseToken, isEmpty); @@ -856,19 +807,16 @@ void main() { search.complete(const [ TorrentRelease(title: 'New', releaseToken: 'token-new', protocol: 'torrent', indexer: 'Prowlarr'), ]); - await operation; + await operation; expect(provider.remoteResults.single.releaseToken, 'token-new'); - provider.dispose(); }); test('makes submission outcome failures immutable at the boundary', () { final failures = {'token-1': 'failed'}; final outcome = AcquisitionSubmissionOutcome(successfulCount: 0, failuresByReleaseToken: failures); - failures['token-2'] = 'later mutation'; - expect(outcome.failuresByReleaseToken, {'token-1': 'failed'}); expect(() => outcome.failuresByReleaseToken['token-3'] = 'not allowed', throwsUnsupportedError); }); @@ -882,32 +830,28 @@ void main() { offset: 0, ), ); + final provider = AcquisitionDownloadsProvider( gateway: gateway, visiblePollingInterval: const Duration(seconds: 2), foregroundPollingInterval: const Duration(seconds: 10), ); - provider.setLibraryVisible(true); + provider.setLibraryVisible(true); await provider.refreshJobs(); expect(gateway.listJobCalls, 1); - await tester.pump(const Duration(seconds: 2)); await tester.pump(); expect(gateway.listJobCalls, 2); - provider.didChangeAppLifecycleState(AppLifecycleState.paused); await tester.pump(const Duration(seconds: 4)); expect(gateway.listJobCalls, 2); - provider.didChangeAppLifecycleState(AppLifecycleState.resumed); await tester.pump(); expect(gateway.listJobCalls, 3); - await tester.pump(const Duration(seconds: 2)); await tester.pump(); expect(gateway.listJobCalls, 4); - provider.dispose(); }); @@ -923,6 +867,7 @@ void main() { ), ], ); + final provider = AcquisitionDownloadsProvider( gateway: gateway, visiblePollingInterval: const Duration(seconds: 2), @@ -930,19 +875,14 @@ void main() { ); expect(provider.jobs, isEmpty); - provider.setLibraryVisible(true); await tester.pump(); - expect(gateway.listJobCalls, 1); expect(provider.jobs, isEmpty); - await tester.pump(const Duration(seconds: 2)); await tester.pump(); - expect(gateway.listJobCalls, 2); expect(provider.jobs.single.id, 'external-job'); - provider.dispose(); }); @@ -955,6 +895,7 @@ void main() { offset: 0, ), ); + final provider = AcquisitionDownloadsProvider( gateway: gateway, visiblePollingInterval: const Duration(seconds: 2), @@ -963,19 +904,17 @@ void main() { await provider.refreshJobs(); expect(gateway.listJobCalls, 1); - await tester.pump(const Duration(seconds: 9)); expect(gateway.listJobCalls, 1); - await tester.pump(const Duration(seconds: 1)); await tester.pump(); expect(gateway.listJobCalls, 2); - provider.dispose(); }); testWidgets('stops foreground polling when the hidden provider has no active jobs', (tester) async { final gateway = _FakeGateway(); + final provider = AcquisitionDownloadsProvider( gateway: gateway, visiblePollingInterval: const Duration(seconds: 2), @@ -984,16 +923,15 @@ void main() { await provider.refreshJobs(); expect(gateway.listJobCalls, 1); - await tester.pump(const Duration(seconds: 20)); await tester.pump(); expect(gateway.listJobCalls, 1); - provider.dispose(); }); testWidgets('resume performs one hidden empty discovery refresh without leaving a timer', (tester) async { final gateway = _FakeGateway(); + final provider = AcquisitionDownloadsProvider( gateway: gateway, visiblePollingInterval: const Duration(seconds: 2), @@ -1003,13 +941,10 @@ void main() { provider.didChangeAppLifecycleState(AppLifecycleState.paused); provider.didChangeAppLifecycleState(AppLifecycleState.resumed); await tester.pump(); - expect(gateway.listJobCalls, 1); - await tester.pump(const Duration(seconds: 10)); await tester.pump(); expect(gateway.listJobCalls, 1); - provider.dispose(); }); @@ -1022,7 +957,9 @@ void main() { offset: 0, ), ); + final replacementGateway = _FakeGateway(); + final provider = AcquisitionDownloadsProvider( gateway: oldGateway, visiblePollingInterval: const Duration(seconds: 2), @@ -1032,13 +969,10 @@ void main() { await provider.refreshJobs(); provider.setGateway(replacementGateway); await tester.pump(); - expect(oldGateway.listJobCalls, 1); expect(replacementGateway.listJobCalls, 1); - await tester.pump(const Duration(seconds: 10)); await tester.pump(); - expect(oldGateway.listJobCalls, 1); expect(replacementGateway.listJobCalls, 1); @@ -1050,18 +984,17 @@ void main() { offset: 0, ), ); + final disposableProvider = AcquisitionDownloadsProvider( gateway: disposalGateway, foregroundPollingInterval: const Duration(seconds: 10), ); + await disposableProvider.refreshJobs(); disposableProvider.dispose(); - await tester.pump(const Duration(seconds: 10)); await tester.pump(); - expect(disposalGateway.listJobCalls, 1); - provider.dispose(); }); @@ -1077,19 +1010,16 @@ void main() { ), ], ); - final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); provider.setLibraryVisible(true); await tester.pump(); expect(provider.jobs, isEmpty); - provider.didChangeAppLifecycleState(AppLifecycleState.paused); provider.didChangeAppLifecycleState(AppLifecycleState.resumed); await tester.pump(); - expect(gateway.listJobCalls, 2); expect(provider.jobs.single.id, 'resumed-job'); - provider.dispose(); }); } @@ -1164,24 +1094,29 @@ class _FakeGateway implements AcquisitionDownloadsGateway { Future listJobs({int limit = 50, int offset = 0}) async { listJobCalls += 1; jobOffsets.add(offset); + if (jobListError case final error?) { throw error; } + if (_jobResponses.isNotEmpty) { final response = _jobResponses.removeAt(0); if (response is Future) { return response; } + if (response is AcquisitionJobPage) { return response; } throw response; } + if (jobPages.isNotEmpty) { return jobPages.removeAt(0); } + return repeatedJobPage ?? const AcquisitionJobPage(items: [], total: 0, limit: 50, offset: 0); } @@ -1192,21 +1127,25 @@ class _FakeGateway implements AcquisitionDownloadsGateway { }) async { submittedTokens = releases.map((release) => release.releaseToken).toList(); submittedTokenBatches.add([...submittedTokens]); + if (_batchResponses.isNotEmpty) { final response = _batchResponses.removeAt(0); if (response is Future) { return response; } + if (response is BatchSubmissionResponse) { return response; } throw response; } + if (batchCompleter case final completer?) { return completer.future; } + return batchResult; } @@ -1222,6 +1161,7 @@ class _FakeGateway implements AcquisitionDownloadsGateway { if (response is Future>) { return response; } + if (response is List) { return response; } diff --git a/app/test/acquisition/acquisition_models_test.dart b/app/test/acquisition/acquisition_models_test.dart index abdcdb6b..23d4b8ea 100644 --- a/app/test/acquisition/acquisition_models_test.dart +++ b/app/test/acquisition/acquisition_models_test.dart @@ -35,11 +35,13 @@ void main() { }); expect(capabilities.indexerKinds, [AcquisitionEndpointKind.prowlarr, AcquisitionEndpointKind.torznab]); + expect(capabilities.downloadClientKinds, [ AcquisitionEndpointKind.qbittorrent, AcquisitionEndpointKind.transmission, AcquisitionEndpointKind.deluge, ]); + expect(capabilities.arrCommands[AcquisitionEndpointKind.readarr], ['AuthorSearch', 'BookSearch']); expect(capabilities.enabled, isTrue); expect(capabilities.managedDownloadsReady, isTrue); @@ -97,7 +99,6 @@ void main() { test('maps an unknown future job state without failing the jobs response', () { final job = AcquisitionJob.fromJson(_jobJson(status: 'verifying')); - expect(job.status, AcquisitionJobStatus.unknown); expect(job.rawStatus, 'verifying'); expect(job.requiresAttention, isTrue); @@ -106,7 +107,6 @@ void main() { test('allows import retry only after qBittorrent submission succeeded', () { final rejected = AcquisitionJob.fromJson(_jobJson(status: 'failed')); final submitted = AcquisitionJob.fromJson(_jobJson(status: 'failed', submittedAt: '2026-07-25T12:00:00Z')); - expect(rejected.canRetryImport, isFalse); expect(submitted.canRetryImport, isTrue); }); @@ -118,12 +118,14 @@ void main() { 'limit': 50, 'offset': 0, }); + final batch = BatchSubmissionResponse.fromJson({ 'items': [ {'index': 0, 'job': _jobJson(status: 'submitted'), 'error': null}, {'index': 1, 'job': null, 'error': 'Release token expired'}, ], }); + final candidate = AcquisitionFileCandidate.fromJson({ 'index': 2, 'name': 'Example.epub', diff --git a/app/test/acquisition/acquisition_user_messages_test.dart b/app/test/acquisition/acquisition_user_messages_test.dart index ffa97fc0..60b7cfda 100644 --- a/app/test/acquisition/acquisition_user_messages_test.dart +++ b/app/test/acquisition/acquisition_user_messages_test.dart @@ -9,6 +9,7 @@ void main() { searchErrorMessage(const AuthApiException(statusCode: 401, message: 'raw server response')), 'Your session expired. Sign in and try again.', ); + expect( searchErrorMessage(const AuthApiException(statusCode: 403, message: 'raw server response')), 'Your session expired. Sign in and try again.', @@ -29,6 +30,7 @@ void main() { searchErrorMessage(const AuthApiException(statusCode: 422, message: 'raw server response')), 'Could not search connected sources. Try again.', ); + expect( searchErrorMessage(StateError('https://server.local/secret')), 'Could not search connected sources. Try again.', @@ -46,6 +48,7 @@ void main() { test('hides timeout details', () { expect(submissionErrorMessage('request timed out after 30s'), 'The download client did not respond in time.'); + expect( submissionErrorMessage('timeout while contacting endpoint-17'), 'The download client did not respond in time.', @@ -64,6 +67,7 @@ void main() { submissionErrorMessage('remote client said: https://client.local/path'), 'This release could not be sent to the download client.', ); + expect(submissionErrorMessage(null), 'This release could not be sent to the download client.'); }); }); @@ -71,7 +75,6 @@ void main() { group('download operation error messages', () { test('uses safe task-specific messages for provider operation failures', () { final rawError = StateError('https://download-client.local/endpoint-17?token=secret'); - expect(configurationErrorMessage(rawError), 'Could not load download settings. Try again.'); expect(jobRefreshErrorMessage(rawError), 'Could not refresh downloads. Try again.'); expect(cancelDownloadErrorMessage(rawError), 'Could not cancel the download. Try again.'); diff --git a/app/test/auth/auth_api_client_test.dart b/app/test/auth/auth_api_client_test.dart index 9c44906f..4d0dc8c5 100644 --- a/app/test/auth/auth_api_client_test.dart +++ b/app/test/auth/auth_api_client_test.dart @@ -30,6 +30,7 @@ void main() { config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), httpClient: MockClient((request) async { expect(request.url.path, '/v1/auth/login'); + expect(jsonDecode(request.body), { 'email': 'reader@example.com', 'password': 'password123', @@ -74,9 +75,7 @@ void main() { test('googleOAuthStartUri builds server-owned browser flow URL', () { final client = AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test'))); - final uri = client.googleOAuthStartUri('papyrus://auth/callback'); - expect(uri.path, '/v1/auth/oauth/google/start'); expect(uri.queryParameters['redirect_uri'], 'papyrus://auth/callback'); }); @@ -104,13 +103,11 @@ void main() { httpClient: MockClient((request) async { expect(request.url.path, '/v1/auth/powersync-token'); expect(request.headers['Authorization'], 'Bearer access-token'); - return http.Response(jsonEncode({'token': 'powersync-token', 'expires_in': 300}), 200); }), ); final token = await client.powerSyncToken('access-token'); - expect(token.token, 'powersync-token'); expect(token.expiresIn, 300); }); @@ -121,6 +118,7 @@ void main() { httpClient: MockClient((request) async { expect(request.url.path, '/v1/sync/powersync-upload'); expect(request.headers['Authorization'], 'Bearer access-token'); + expect(jsonDecode(request.body), { 'batch': [ { @@ -152,13 +150,11 @@ void main() { httpClient: MockClient((request) async { expect(request.url.path, '/v1/media/usage'); expect(request.headers['Authorization'], 'Bearer access-token'); - return http.Response(jsonEncode({'used_bytes': 10, 'quota_bytes': 100, 'available_bytes': 90}), 200); }), ); final usage = await client.fetchMediaUsage('access-token'); - expect(usage.usedBytes, 10); expect(usage.quotaBytes, 100); expect(usage.availableBytes, 90); @@ -170,13 +166,11 @@ void main() { httpClient: MockClient((request) async { expect(request.url.path, '/v1/media/asset-id'); expect(request.headers['Authorization'], 'Bearer access-token'); - return http.Response.bytes([1, 2, 3], 200, headers: {'content-type': 'application/epub+zip'}); }), ); final bytes = await client.downloadMedia('access-token', 'asset-id'); - expect(bytes, Uint8List.fromList([1, 2, 3])); }); @@ -238,6 +232,7 @@ class _CapturingMultipartClient extends http.BaseClient { Future send(http.BaseRequest request) async { final body = await request.finalize().bytesToString(); final response = await handler(request, body); + return http.StreamedResponse( Stream.value(response.bodyBytes), response.statusCode, diff --git a/app/test/auth/auth_repository_powersync_test.dart b/app/test/auth/auth_repository_powersync_test.dart index f0581aee..216ccf67 100644 --- a/app/test/auth/auth_repository_powersync_test.dart +++ b/app/test/auth/auth_repository_powersync_test.dart @@ -30,6 +30,7 @@ void main() { var powerSyncTokenCalls = 0; final store = TokenStore(MemoryRefreshTokenStorage()); await store.saveTokens(accessToken: 'expired-access', refreshToken: 'refresh-token'); + final repository = AuthRepository( apiClient: AuthApiClient( config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), @@ -62,7 +63,6 @@ void main() { ); final token = await repository.createPowerSyncToken(); - expect(token.token, 'powersync-token'); expect(powerSyncTokenCalls, 2); expect(store.accessToken, 'fresh-access'); diff --git a/app/test/auth/token_store_test.dart b/app/test/auth/token_store_test.dart index cd1f1408..c37553f7 100644 --- a/app/test/auth/token_store_test.dart +++ b/app/test/auth/token_store_test.dart @@ -22,15 +22,12 @@ void main() { test('TokenStore saves, rotates, and clears tokens', () async { final storage = MemoryRefreshTokenStorage(); final store = TokenStore(storage); - await store.saveTokens(accessToken: 'access-one', refreshToken: 'refresh-one'); expect(store.accessToken, 'access-one'); expect(await store.readRefreshToken(), 'refresh-one'); - await store.saveTokens(accessToken: 'access-two', refreshToken: 'refresh-two'); expect(store.accessToken, 'access-two'); expect(await store.readRefreshToken(), 'refresh-two'); - await store.clear(); expect(store.accessToken, isNull); expect(await store.readRefreshToken(), isNull); diff --git a/app/test/config/app_router_test.dart b/app/test/config/app_router_test.dart index fbafa41d..cf7685b6 100644 --- a/app/test/config/app_router_test.dart +++ b/app/test/config/app_router_test.dart @@ -81,6 +81,7 @@ void main() { await auth.bootstrap(); final preferences = PreferencesProvider(prefs); final sync = _syncSettings(prefs); + final availability = AcquisitionAvailabilityProvider( loadCapabilities: (_) async => const AcquisitionCapabilities( enabled: false, @@ -91,16 +92,19 @@ void main() { arrCommands: {}, ), ); + final appRouter = AppRouter( authProvider: auth, preferencesProvider: preferences, syncSettingsProvider: sync, acquisitionAvailabilityProvider: availability, ); + final store = createTestDataStore(books: [], shelves: []); final library = LibraryProvider(); final sidebar = SidebarProvider(); final catalogs = OpdsCatalogs(OpdsCatalogStore(prefs))..setScope('guest'); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); appRouter.router.dispose(); @@ -113,6 +117,7 @@ void main() { sidebar.dispose(); catalogs.dispose(); }); + await tester.pumpWidget( MultiProvider( providers: [ @@ -125,8 +130,10 @@ void main() { child: MaterialApp.router(theme: AppTheme.dark, routerConfig: appRouter.router), ), ); + await tester.pumpAndSettle(); expect(appRouter.rootNavigatorKey.currentState!.widget.pages, everyElement(isA())); + if (offline) { await tester.tap(find.text('Continue offline')); } else { @@ -136,6 +143,7 @@ void main() { await tester.enterText(find.widgetWithText(TextFormField, 'Password'), 'SecureP@ss123'); await tester.tap(find.text('Continue')); } + await tester.pumpAndSettle(); expect(appRouter.router.routeInformationProvider.value.uri.path, '/library/books'); expect(find.byType(AdaptiveAppShell), findsOneWidget); @@ -153,11 +161,8 @@ void main() { test('redirects signed-out users away from protected routes', () async { final prefs = await SharedPreferences.getInstance(); final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); - await provider.bootstrap(); - final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); - expect(appRouter.redirectForPath('/library/books'), '/'); expect(appRouter.redirectForPath('/login'), isNull); expect(appRouter.redirectForPath('/reset-password'), isNull); @@ -167,11 +172,8 @@ void main() { final prefs = await SharedPreferences.getInstance(); final repository = FakeAuthRepository()..bootstrapResult = _tokens(); final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); - await provider.bootstrap(); - final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); - expect(appRouter.redirectForPath('/login'), '/library/books'); expect(appRouter.redirectForPath('/reset-password'), '/library/books'); expect(appRouter.redirectForPath('/library/books'), isNull); @@ -180,12 +182,9 @@ void main() { test('offline mode bypasses protected-route auth redirect', () async { final prefs = await SharedPreferences.getInstance(); final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); - await provider.bootstrap(); provider.setOfflineMode(true); - final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); - expect(appRouter.redirectForPath('/library/books'), isNull); }); @@ -193,7 +192,6 @@ void main() { final prefs = await SharedPreferences.getInstance(); final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); - expect(appRouter.router.namedLocation('BOOK_EDIT', pathParameters: {'bookId': 'book-1'}), '/library/edit/book-1'); }); @@ -201,7 +199,6 @@ void main() { final prefs = await SharedPreferences.getInstance(); final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); final appRouter = await _buildRouter(authProvider: provider, prefs: prefs); - expect(appRouter.router.namedLocation('BOOK_READER', pathParameters: {'bookId': 'book-1'}), '/library/read/book-1'); }); @@ -211,6 +208,7 @@ void main() { final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); final preferences = PreferencesProvider(prefs); final syncSettings = _syncSettings(prefs); + final availability = AcquisitionAvailabilityProvider( loadCapabilities: (_) async => const AcquisitionCapabilities( enabled: true, @@ -232,19 +230,16 @@ void main() { ); expect(appRouter.redirectForPath('/acquisition'), '/profile'); - preferences.acquisitionEnabled = true; - expect(appRouter.redirectForPath('/acquisition'), '/profile'); - await availability.refresh(syncSettings.activeApiConfig.serverBaseUri); - expect(appRouter.redirectForPath('/acquisition'), isNull); }); } Future _buildRouter({required AuthProvider authProvider, required SharedPreferences prefs}) async { final syncSettings = _syncSettings(prefs); + return AppRouter( authProvider: authProvider, preferencesProvider: PreferencesProvider(prefs), diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 7707354a..8d3a2d50 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -23,7 +23,11 @@ class FakeBookRepository implements BookRepository { @override Future delete(String id) async { await deleteGate?.future; - if (deleteError != null) throw deleteError!; + + if (deleteError != null) { + throw deleteError!; + } + deletes.add(id); } @@ -35,7 +39,11 @@ class FakeBookRepository implements BookRepository { @override Future upsert(Book book) async { await upsertGate?.future; - if (upsertError != null) throw upsertError!; + + if (upsertError != null) { + throw upsertError!; + } + upserts.add(book); } @@ -55,16 +63,12 @@ void main() { test('repository stream is the source of the DataStore book snapshot', () async { final repository = FakeBookRepository(); final store = DataStore(); - await store.attachBookRepository(repository); repository.controller.add([_book('one', 'First')]); await pumpEventQueue(); - expect(store.books.map((book) => book.title), ['First']); - repository.controller.add([_book('two', 'Second')]); await pumpEventQueue(); - expect(store.books.map((book) => book.title), ['Second']); await store.disposeBookRepository(); await repository.controller.close(); @@ -74,14 +78,11 @@ void main() { final repository = FakeBookRepository(); final store = DataStore()..loadData(books: [_book('old', 'Old book')]); expect(store.isLoaded, isTrue); - await store.attachBookRepository(repository); - expect(store.isLoaded, isFalse); repository.controller.add(const []); await pumpEventQueue(); expect(store.isLoaded, isTrue); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -90,14 +91,11 @@ void main() { final repository = FakeBookRepository(); final store = DataStore(bookRepository: repository); var completed = false; - final loaded = store.waitUntilLoaded().then((_) => completed = true); await pumpEventQueue(); expect(completed, isFalse); - repository.controller.add([_book('one', 'First')]); await loaded; - expect(store.isLoaded, isTrue); expect(store.getBook('one')?.title, 'First'); await store.disposeBookRepository(); @@ -108,16 +106,12 @@ void main() { final repository = FakeBookRepository(); final store = DataStore(bookRepository: repository); final syncedBook = _book('one', 'First'); - repository.controller.add([syncedBook]); await pumpEventQueue(); - store.updateBook(syncedBook.copyWith(coverMediaId: 'cover-asset')); repository.controller.add([syncedBook]); await pumpEventQueue(); - expect(store.getBook(syncedBook.id)?.coverMediaId, 'cover-asset'); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -126,13 +120,11 @@ void main() { final repository = FakeBookRepository(); final store = DataStore(); final book = _book('one', 'First'); - await store.attachBookRepository(repository); store.addBook(book); store.updateBook(book.copyWith(title: 'Updated')); store.deleteBook(book.id); await pumpEventQueue(); - expect(repository.upserts.map((item) => item.title), ['First', 'Updated']); expect(repository.deletes, ['one']); await store.disposeBookRepository(); @@ -145,12 +137,9 @@ void main() { final book = _book('one', 'First'); var notifications = 0; store.addListener(() => notifications++); - store.addBook(book); - expect(store.getBook(book.id), same(book)); expect(notifications, 1); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -158,7 +147,6 @@ void main() { test('legacy addBook and deleteBook reject a missing repository synchronously', () async { final store = DataStore(); await store.disposeBookRepository(); - expect(() => store.addBook(_book('one', 'First')), throwsStateError); expect(() => store.deleteBook('one'), throwsStateError); }); @@ -167,9 +155,9 @@ void main() { final repository = FakeBookRepository() ..upsertGate = Completer() ..deleteGate = Completer(); + final store = DataStore(bookRepository: repository); final book = _book('one', 'First'); - var addCompleted = false; final add = store.addBookAndWait(book).then((_) => addCompleted = true); await pumpEventQueue(); @@ -177,7 +165,6 @@ void main() { repository.upsertGate!.complete(); await add; expect(repository.upserts, [book]); - var deleteCompleted = false; final delete = store.deleteBookAndWait(book.id).then((_) => deleteCompleted = true); await pumpEventQueue(); @@ -185,7 +172,6 @@ void main() { repository.deleteGate!.complete(); await delete; expect(repository.deletes, [book.id]); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -193,14 +179,14 @@ void main() { test('awaitable book mutations surface repository errors', () async { final addFailure = StateError('upsert failed'); final deleteFailure = StateError('delete failed'); + final repository = FakeBookRepository() ..upsertError = addFailure ..deleteError = deleteFailure; - final store = DataStore(bookRepository: repository); + final store = DataStore(bookRepository: repository); await expectLater(store.addBookAndWait(_book('one', 'First')), throwsA(same(addFailure))); await expectLater(store.deleteBookAndWait('one'), throwsA(same(deleteFailure))); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -210,12 +196,9 @@ void main() { final store = DataStore(bookRepository: repository); final captured = store.requireBookRepository(); final book = _book('one', 'First'); - await store.addBookToRepositoryAndWait(captured, book); - expect(store.isBookRepositoryCurrent(captured), isTrue); expect(store.getBook(book.id), same(book)); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -228,13 +211,10 @@ void main() { final book = _book('one', 'First'); await store.addBookToRepositoryAndWait(captured, book); await store.attachBookRepository(second); - await store.deleteBookFromRepositoryAndWait(captured, book.id); - expect(first.deletes, [book.id]); expect(second.deletes, isEmpty); expect(store.isBookRepositoryCurrent(captured), isFalse); - await store.disposeBookRepository(); await first.controller.close(); await second.controller.close(); @@ -246,13 +226,10 @@ void main() { final book = _book('book-1', 'First'); final createdAt = DateTime.utc(2026, 1, 1); final shelf = Shelf(id: 'shelf-1', name: 'Shelf', createdAt: createdAt, updatedAt: createdAt); - store.addBook(book); store.addShelf(shelf); store.addBookToShelf(book.id, shelf.id); - expect(store.getCoverPreviewsForShelf(shelf.id).single.bookId, book.id); - await store.disposeBookRepository(); await repository.controller.close(); }); @@ -292,7 +269,6 @@ void main() { await pumpEventQueue(); expect(repository.upserts.last.fileMediaId, isNull); expect(repository.upserts.last.fileHash, 'hash'); - await store.disposeBookRepository(); await repository.controller.close(); }); diff --git a/app/test/goals/goal_progress_test.dart b/app/test/goals/goal_progress_test.dart index 94bceb9e..f0e3fac1 100644 --- a/app/test/goals/goal_progress_test.dart +++ b/app/test/goals/goal_progress_test.dart @@ -8,10 +8,15 @@ 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 ledger = (fixture['activities'] as List).map( + (item) => ReadingActivity.fromJson(item as Map), + ); + final now = DateTime.parse(fixture['now'] as String); final totals = projectGoal(goal, ledger, now); final expected = fixture['expected'] as Map; @@ -22,21 +27,26 @@ void main() { expect(totals.value, expected['current_value']); }); } + test('eligible activity durations preserve originals and merge grouped checkpoints', () { final fixture = fixtures.last as Map; final goal = ReadingGoal.fromJson(fixture['goal'] as Map); + final ledger = (fixture['activities'] as List) - .map((a) => ReadingActivity.fromJson(a as Map)) + .map((item) => ReadingActivity.fromJson(item 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( @@ -51,6 +61,7 @@ void main() { ), ) .toList(); + final readerProgress = projectGoal(goal, reader, DateTime.parse(fixture['now'] as String)); final grouped = groupReadingActivities(readerProgress.activities).single; expect(grouped.seconds, 1800); @@ -61,8 +72,10 @@ void main() { 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, @@ -72,6 +85,7 @@ void main() { endDate: start.add(const Duration(days: 1)), createdAt: start, ); + final sessions = [ for (var i = 0; i < 3; i++) ReadingActivity( @@ -83,11 +97,13 @@ void main() { 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), @@ -95,12 +111,14 @@ void main() { 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'); @@ -109,8 +127,10 @@ void main() { 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, @@ -124,11 +144,14 @@ void main() { 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', @@ -139,6 +162,7 @@ void main() { 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 index 49cbe2da..a8171ee4 100644 --- a/app/test/goals/goals_provider_test.dart +++ b/app/test/goals/goals_provider_test.dart @@ -9,6 +9,7 @@ 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(); @@ -22,15 +23,18 @@ void main() { 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); @@ -43,7 +47,7 @@ void main() { 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 confirmations = store.effectiveReadingActivities.where((activity) => activity.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); @@ -54,6 +58,7 @@ void main() { 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); @@ -66,6 +71,7 @@ void main() { 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, @@ -77,7 +83,9 @@ void main() { ), throwsArgumentError, ); + expect(store.goalDefinitions.single.isArchived, isFalse); + await provider.createGoal( type: GoalType.pages, target: 100, @@ -87,6 +95,7 @@ void main() { replaceGoalId: original.id, timezone: 'UTC', ); + final retained = store.getReadingGoal(original.id)!; expect(retained.isArchived, isTrue); expect(retained.type, GoalType.minutes); @@ -99,15 +108,19 @@ void main() { expect(provider.current.single.pages, 5); expect(provider.progress(retained).seconds, 600); }); + test('selected books are canonical, bounded, durable and exclude other completions', () async { var now = DateTime.utc(2026, 10, 5, 12); + final books = [ for (final id in ['a', 'b', 'c']) Book(id: id, title: id, author: '', addedAt: now), ]; + final store = DataStore()..loadData(books: books); final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); addTearDown(provider.dispose); addTearDown(store.dispose); + await expectLater( provider.createGoal( type: GoalType.books, @@ -119,6 +132,7 @@ void main() { ), throwsArgumentError, ); + await expectLater( provider.createGoal( type: GoalType.books, @@ -130,6 +144,7 @@ void main() { ), throwsArgumentError, ); + await expectLater( provider.createGoal( type: GoalType.minutes, @@ -141,6 +156,7 @@ void main() { ), throwsArgumentError, ); + await provider.createGoal( type: GoalType.books, target: 2, @@ -149,16 +165,23 @@ void main() { 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'); + + final completion = store.effectiveReadingActivities.firstWhere( + (activity) => activity.kind == 'completion' && activity.bookId == 'b', + ); + await provider.reverseActivity(completion); expect(provider.current.single.finishedBooks, 1); final single = goal.copyWith(bookIds: [], scopeId: 'b'); @@ -190,7 +213,7 @@ void main() { 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'); + final completion = store.effectiveReadingActivities.firstWhere((activity) => activity.kind == 'completion'); await provider.reverseActivity(completion); expect(store.getBook(book.id)!.readingStatus, LibraryReadingStatus.inProgress); await provider.deleteGoal(store.goalDefinitions.single.id); diff --git a/app/test/goals/reading_activity_calendar_test.dart b/app/test/goals/reading_activity_calendar_test.dart index 2785deee..2a144513 100644 --- a/app/test/goals/reading_activity_calendar_test.dart +++ b/app/test/goals/reading_activity_calendar_test.dart @@ -5,6 +5,7 @@ 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, @@ -21,12 +22,14 @@ void main() { 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); }); @@ -38,6 +41,7 @@ void main() { 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); }); @@ -46,6 +50,7 @@ void main() { 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( @@ -61,6 +66,7 @@ void main() { 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/helpers/test_helpers.dart b/app/test/helpers/test_helpers.dart index a4f0a03f..3ef38c33 100644 --- a/app/test/helpers/test_helpers.dart +++ b/app/test/helpers/test_helpers.dart @@ -66,6 +66,7 @@ Widget createTestPage({ /// Creates test books with various states for filtering tests. List createTestBooks() { final now = DateTime.now(); + return [ Book( id: 'book-1', @@ -124,6 +125,7 @@ List createTestBooks() { DataStore createTestDataStore({List? books, List? shelves, List? tags}) { final now = DateTime.now(); final store = DataStore(); + store.loadData( books: books ?? createTestBooks(), shelves: @@ -139,13 +141,13 @@ DataStore createTestDataStore({List? books, List? shelves, List null).resolve(ImageConfiguration.empty); + final listener = ImageStreamListener( (_, _) {}, onError: (Object error, StackTrace? stackTrace) { @@ -57,12 +57,12 @@ void main() { await tester.pump(); await tester.pump(); stream.removeListener(listener); - expect(streamError, isA()); }); testWidgets('evicting a key makes an equal provider load again', (tester) async { var loads = 0; + Future load() async { loads++; return _pngBytes; @@ -82,7 +82,6 @@ void main() { ); await _pumpImage(tester, _provider(loadBytes: load)); - expect(loads, 2); }); } diff --git a/app/test/media/media_cache_service_test.dart b/app/test/media/media_cache_service_test.dart index 9d23c9ea..eb2345df 100644 --- a/app/test/media/media_cache_service_test.dart +++ b/app/test/media/media_cache_service_test.dart @@ -108,7 +108,6 @@ void main() { final first = await load(); final second = await load(); - expect(first, Uint8List.fromList([1, 2, 3])); expect(second, first); expect(downloads, 1); @@ -138,7 +137,6 @@ void main() { await Future.delayed(Duration.zero); expect(downloads, 1); gate.complete(Uint8List.fromList([1, 2, 3])); - expect(await first, await second); expect(downloads, 1); }); @@ -156,7 +154,11 @@ void main() { writeLocalCover: (_, _, _) async {}, downloadMedia: (_) async { downloads++; - if (downloads == 1) throw StateError('offline'); + + if (downloads == 1) { + throw StateError('offline'); + } + return Uint8List.fromList([1]); }, ); diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 58c11d27..3a5f5955 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -5,21 +5,26 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('OPDS catalogs become available only after the target database activates', () { final source = File('lib/main.dart').readAsStringSync(); + final activation = source.substring( source.indexOf('Future _applyPowerSyncAuthState()'), source.indexOf('void _clearAuthStateOperation'), ); + expect( activation.indexOf('_opdsCatalogs.setScope(scope.persistenceKey)'), greaterThan(activation.indexOf('await _powerSyncService.activateAuthenticated')), ); + expect( activation.indexOf('_opdsCatalogs.setScope(MediaStorageScope.localGuest.persistenceKey)'), greaterThan(activation.indexOf('await _powerSyncService.activateGuest')), ); }); + test('profile switch publishes its key with the replacement repository', () { final source = File('lib/main.dart').readAsStringSync(); + final handler = source.substring( source.indexOf('void _handleSyncSettingsChanged()'), source.indexOf('Future _refreshMediaUsage()'), @@ -31,6 +36,7 @@ void main() { test('upload processing rechecks profile transition after scope activation', () { final source = File('lib/main.dart').readAsStringSync(); + final processor = source.substring( source.indexOf('Future _processMediaUploads()'), source.indexOf('@override\n Widget build'), @@ -42,6 +48,7 @@ void main() { test('successful cover upload promotes the captured pending cover best effort', () { final source = File('lib/main.dart').readAsStringSync(); + final processor = source.substring( source.indexOf('Future _processMediaUploads()'), source.indexOf('@override\n Widget build'), @@ -55,12 +62,13 @@ void main() { test('production import delegates scoped cover persistence and queueing to the commit boundary', () { final mainSource = File('lib/main.dart').readAsStringSync(); + final processor = mainSource.substring( mainSource.indexOf('Future _processMediaUploads()'), mainSource.indexOf('@override\n Widget build'), ); - expect(processor, contains('readPendingCover: _bookImportService.getPendingCoverFile')); + expect(processor, contains('readPendingCover: _bookImportService.getPendingCoverFile')); final importSource = File('lib/widgets/add_book/book_import_sheet.dart').readAsStringSync(); final commitStart = importSource.indexOf('static Future _commitResult('); final commit = importSource.substring(commitStart, importSource.indexOf('@override', commitStart)); diff --git a/app/test/media/media_storage_scope_test.dart b/app/test/media/media_storage_scope_test.dart index 7bb5f4b7..20d47b99 100644 --- a/app/test/media/media_storage_scope_test.dart +++ b/app/test/media/media_storage_scope_test.dart @@ -11,7 +11,6 @@ void main() { test('scope key isolates server and user', () { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final second = MediaStorageScope(profileKey: 'custom-deadbeef', userId: 'user-1'); - expect(first.persistenceKey, isNot(second.persistenceKey)); expect(first.persistenceKey, 'official--user-1'); }); @@ -20,7 +19,6 @@ void main() { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final same = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final otherUser = MediaStorageScope(profileKey: 'official', userId: 'user-2'); - expect(first, same); expect(first.hashCode, same.hashCode); expect(first, isNot(otherUser)); diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index 2a2990dc..ade29902 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -40,6 +40,7 @@ void main() { return uploadResult.future; }, ); + await uploadStarted.future; // A downloaded server snapshot can replace the in-memory copy while the @@ -49,7 +50,6 @@ void main() { uploadResult.complete(_asset(assetId: 'file-asset', bookId: book.id, kind: MediaKind.bookFile)); await processing; await pumpEventQueue(); - expect(queue.pendingTasks, isEmpty); final persisted = await repository.getById(book.id); expect(persisted?.fileFormat, BookFormat.epub); @@ -100,7 +100,6 @@ void main() { ); await queue.retryFailed(); - expect(queue.pendingTasks.single.status, MediaUploadTaskStatus.pending); expect(queue.pendingTasks.single.errorMessage, isNull); }); @@ -109,12 +108,9 @@ void main() { final prefs = await SharedPreferences.getInstance(); final queue = await _activeQueue(prefs); final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); - await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); - await queue.removeTasksForBook(book.id); - expect(queue.pendingTasks, isEmpty); expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); }); @@ -123,9 +119,7 @@ void main() { final prefs = await SharedPreferences.getInstance(); final queue = await _activeQueue(prefs); final book = _book(filePath: 'book-1'); - await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); - final stored = jsonDecode(prefs.getString('media_upload_queue:official--user-1')!) as List; final task = stored.single as Map; expect(task, isNot(contains('cover_base64'))); @@ -228,6 +222,7 @@ void main() { await pumpEventQueue(); final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final coverBytes = Uint8List.fromList('legacy cover'.codeUnits); + await prefs.setString( 'media_upload_queue:${scope.persistenceKey}', jsonEncode([ @@ -243,6 +238,7 @@ void main() { }, ]), ); + final queue = MediaUploadQueue(prefs); await queue.activateScope(scope); var attempts = 0; @@ -254,21 +250,24 @@ void main() { uploadMedia: (payload) async { attempts++; expect(payload.bytes, coverBytes); + if (attempts == 1) { throw const MediaUploadException.storageFull(); } + return _asset(assetId: 'cover-asset', bookId: book.id, kind: MediaKind.coverImage); }, ); await process(); + final failedTask = (jsonDecode(prefs.getString('media_upload_queue:${scope.persistenceKey}')!) as List).single as Map; + expect(failedTask['cover_base64'], base64Encode(coverBytes)); await queue.retryFailed(); await process(); - expect(attempts, 2); expect(queue.pendingTasks, isEmpty); }); @@ -279,12 +278,10 @@ void main() { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final second = MediaStorageScope(profileKey: 'custom-a', userId: 'user-2'); final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); - await queue.activateScope(first); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); await queue.activateScope(second); expect(queue.pendingTasks, isEmpty); - await queue.activateScope(first); expect(queue.pendingTasks, hasLength(1)); expect(queue.pendingTasks.single.bookId, book.id); @@ -297,9 +294,7 @@ void main() { final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); await queue.activateScope(scope); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); - await queue.activateScope(null); - expect(queue.activeScope, isNull); expect(queue.pendingTasks, isEmpty); }); @@ -334,7 +329,6 @@ void main() { expect(identical(first, second), isTrue); await Future.delayed(Duration.zero); expect(uploads, 1); - gate.complete(_asset(assetId: 'file-asset', bookId: book.id, kind: MediaKind.bookFile)); await Future.wait([first, second]); expect(uploads, 1); @@ -359,11 +353,13 @@ void main() { readPendingCover: (_, _) async => null, uploadMedia: (payload) async { attempts++; + if (attempts == 1) { firstAttemptStarted.complete(); await releaseFirstAttempt.future; throw StateError('book has not synced yet'); } + return _asset(assetId: 'file-asset', bookId: payload.bookId, kind: payload.kind); }, ); @@ -373,7 +369,6 @@ void main() { final retrySignal = process(); releaseFirstAttempt.complete(); await Future.wait([first, retrySignal]); - expect(attempts, 2); expect(queue.pendingTasks, isEmpty); }); @@ -398,32 +393,36 @@ void main() { readPendingCover: (_, _) async => null, uploadMedia: (payload) async { uploadedBookIds.add(payload.bookId); + if (payload.bookId == firstBook.id) { firstUploadStarted.complete(); await releaseFirstUpload.future; } + return _asset(assetId: 'asset-${payload.bookId}', bookId: payload.bookId, kind: payload.kind); }, ); queue = MediaUploadQueue(prefs, onWorkAvailable: process); await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + final firstEnqueue = queue.enqueueBookFile( book: firstBook, filename: 'first.epub', contentType: 'application/epub+zip', ); + await firstUploadStarted.future; + final secondEnqueue = queue.enqueueBookFile( book: secondBook, filename: 'second.epub', contentType: 'application/epub+zip', ); + await pumpEventQueue(); releaseFirstUpload.complete(); - await Future.wait([firstEnqueue, secondEnqueue]); - expect(uploadedBookIds, [firstBook.id, secondBook.id]); expect(queue.pendingTasks, isEmpty); expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); @@ -451,11 +450,11 @@ void main() { throw const MediaUploadException('network failure'); }, ); + await uploadStarted.future; await queue.removeTasksForBook(book.id); releaseUpload.complete(); await processing; - expect(queue.pendingTasks, isEmpty); expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); }); @@ -464,12 +463,14 @@ void main() { final prefs = await SharedPreferences.getInstance(); final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); String? storedAtCallback; + final queue = MediaUploadQueue( prefs, onWorkAvailable: () async { storedAtCallback = prefs.getString('media_upload_queue:${scope.persistenceKey}'); }, ); + await queue.activateScope(scope); await queue.enqueueBookFile( @@ -487,6 +488,7 @@ void main() { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); var callbacks = 0; List? storedAtCallback; + final queue = MediaUploadQueue( prefs, onWorkAvailable: () async { @@ -494,6 +496,7 @@ void main() { storedAtCallback = jsonDecode(prefs.getString('media_upload_queue:${scope.persistenceKey}')!) as List; }, ); + await queue.activateScope(scope); await queue.enqueueImportedBookMedia( @@ -576,6 +579,7 @@ void main() { await pumpEventQueue(); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); callbacks = 0; + await queue.processPending( dataStore: dataStore, readBookFile: (_) async => Uint8List.fromList([1]), @@ -585,7 +589,6 @@ void main() { await queue.retryFailed(); await queue.retryFailed(); - expect(callbacks, 1); }); } diff --git a/app/test/models/annotation_test.dart b/app/test/models/annotation_test.dart index a49701dc..3a81523f 100644 --- a/app/test/models/annotation_test.dart +++ b/app/test/models/annotation_test.dart @@ -38,7 +38,6 @@ void main() { test('creates copy with updated fields', () { const original = BookLocation(chapter: 1, chapterTitle: 'Intro', pageNumber: 5, percentage: 0.01); final copy = original.copyWith(pageNumber: 10, percentage: 0.05); - expect(copy.chapter, 1); expect(copy.chapterTitle, 'Intro'); expect(copy.pageNumber, 10); @@ -71,7 +70,6 @@ void main() { test('creates copy with updated fields', () { final original = buildTestAnnotation(selectedText: 'Original', color: HighlightColor.yellow, note: 'Note'); final copy = original.copyWith(color: HighlightColor.blue, note: 'Updated note'); - expect(copy.selectedText, 'Original'); expect(copy.color, HighlightColor.blue); expect(copy.note, 'Updated note'); @@ -82,6 +80,7 @@ void main() { group('toJson', () { test('serializes all fields including flattened location', () { final now = DateTime(2025, 6, 15); + final annotation = Annotation( id: 'ann-1', bookId: 'book-1', @@ -94,7 +93,6 @@ void main() { ); final json = annotation.toJson(); - expect(json['id'], 'ann-1'); expect(json['book_id'], 'book-1'); expect(json['selected_text'], 'Some text'); @@ -126,7 +124,6 @@ void main() { }; final annotation = Annotation.fromJson(json); - expect(annotation.id, 'ann-1'); expect(annotation.bookId, 'book-1'); expect(annotation.selectedText, 'Important passage'); @@ -167,7 +164,6 @@ void main() { ); final restored = Annotation.fromJson(original.toJson()); - expect(restored.id, original.id); expect(restored.selectedText, original.selectedText); expect(restored.color, original.color); diff --git a/app/test/models/book_test.dart b/app/test/models/book_test.dart index 76a1ad28..0b2f1cd0 100644 --- a/app/test/models/book_test.dart +++ b/app/test/models/book_test.dart @@ -75,7 +75,6 @@ void main() { test('creates copy with updated fields', () { final book = buildTestBook(title: 'Original', isFavorite: false); final copy = book.copyWith(title: 'Updated', isFavorite: true); - expect(copy.title, 'Updated'); expect(copy.isFavorite, true); expect(copy.id, book.id); // unchanged @@ -85,21 +84,18 @@ void main() { test('clearCoverUrl sets coverUrl to null', () { final book = buildTestBook(coverUrl: 'http://example.com/cover.jpg'); final copy = book.copyWith(clearCoverUrl: true); - expect(copy.coverUrl, isNull); }); test('clearCoverUrl false preserves existing coverUrl', () { final book = buildTestBook(coverUrl: 'http://example.com/cover.jpg'); final copy = book.copyWith(clearCoverUrl: false); - expect(copy.coverUrl, 'http://example.com/cover.jpg'); }); test('coverUrl parameter overrides clearCoverUrl', () { final book = buildTestBook(coverUrl: 'http://example.com/old.jpg'); final copy = book.copyWith(coverUrl: 'http://example.com/new.jpg'); - expect(copy.coverUrl, 'http://example.com/new.jpg'); }); }); @@ -107,6 +103,7 @@ void main() { group('toJson', () { test('serializes all fields with snake_case keys', () { final now = DateTime(2025, 6, 15, 10, 30); + final book = Book( id: 'test-id', title: 'Test Title', @@ -142,7 +139,6 @@ void main() { ); final json = book.toJson(); - expect(json['id'], 'test-id'); expect(json['title'], 'Test Title'); expect(json['subtitle'], 'Test Subtitle'); @@ -179,7 +175,6 @@ void main() { test('null optional fields serialize as null', () { final book = buildTestBook(); final json = book.toJson(); - expect(json['subtitle'], isNull); expect(json['isbn'], isNull); expect(json['file_path'], isNull); @@ -229,7 +224,6 @@ void main() { }; final book = Book.fromJson(json); - expect(book.id, 'test-id'); expect(book.title, 'Test Title'); expect(book.subtitle, 'Sub'); @@ -262,9 +256,7 @@ void main() { test('defaults for missing optional fields', () { final json = {'id': 'test', 'title': 'Title', 'author': 'Author', 'added_at': '2025-01-01T00:00:00.000'}; - final book = Book.fromJson(json); - expect(book.coAuthors, isEmpty); expect(book.isPhysical, false); expect(book.readingStatus, LibraryReadingStatus.unread); @@ -296,7 +288,6 @@ void main() { ); final restored = Book.fromJson(original.toJson()); - expect(restored.id, original.id); expect(restored.title, original.title); expect(restored.coAuthors, original.coAuthors); diff --git a/app/test/models/bookmark_test.dart b/app/test/models/bookmark_test.dart index 3db73974..c8109e31 100644 --- a/app/test/models/bookmark_test.dart +++ b/app/test/models/bookmark_test.dart @@ -49,7 +49,6 @@ void main() { test('keeps nullable fields when not passed', () { final bookmark = buildTestBookmark(pageNumber: 10, chapterTitle: 'Ch1', note: 'A note'); final copy = bookmark.copyWith(colorHex: '#FF0000'); - expect(copy.pageNumber, 10); expect(copy.chapterTitle, 'Ch1'); expect(copy.note, 'A note'); @@ -59,35 +58,30 @@ void main() { test('clears pageNumber when explicitly set to null', () { final bookmark = buildTestBookmark(pageNumber: 10); final copy = bookmark.copyWith(pageNumber: null); - expect(copy.pageNumber, isNull); }); test('clears chapterTitle when explicitly set to null', () { final bookmark = buildTestBookmark(chapterTitle: 'Ch1'); final copy = bookmark.copyWith(chapterTitle: null); - expect(copy.chapterTitle, isNull); }); test('clears note when explicitly set to null', () { final bookmark = buildTestBookmark(note: 'A note'); final copy = bookmark.copyWith(note: null); - expect(copy.note, isNull); }); test('updates pageNumber to new value', () { final bookmark = buildTestBookmark(pageNumber: 10); final copy = bookmark.copyWith(pageNumber: 20); - expect(copy.pageNumber, 20); }); test('updates note to new value', () { final bookmark = buildTestBookmark(note: 'Old note'); final copy = bookmark.copyWith(note: 'New note'); - expect(copy.note, 'New note'); }); }); @@ -95,6 +89,7 @@ void main() { group('toJson', () { test('serializes all fields with snake_case keys', () { final now = DateTime(2025, 6, 15); + final bookmark = Bookmark( id: 'bm-1', bookId: 'book-1', @@ -107,7 +102,6 @@ void main() { ); final json = bookmark.toJson(); - expect(json['id'], 'bm-1'); expect(json['book_id'], 'book-1'); expect(json['position'], 0.5); @@ -121,7 +115,6 @@ void main() { test('null optional fields serialize as null', () { final bookmark = buildTestBookmark(); final json = bookmark.toJson(); - expect(json['page_number'], isNull); expect(json['chapter_title'], isNull); expect(json['note'], isNull); @@ -142,7 +135,6 @@ void main() { }; final bookmark = Bookmark.fromJson(json); - expect(bookmark.id, 'bm-1'); expect(bookmark.bookId, 'book-1'); expect(bookmark.position, 0.75); @@ -154,7 +146,6 @@ void main() { test('defaults colorHex when missing', () { final json = {'id': 'bm-1', 'book_id': 'book-1', 'position': 0.5, 'created_at': '2025-01-01T00:00:00.000'}; - final bookmark = Bookmark.fromJson(json); expect(bookmark.colorHex, '#FF5722'); }); @@ -174,7 +165,6 @@ void main() { ); final restored = Bookmark.fromJson(original.toJson()); - expect(restored.id, original.id); expect(restored.bookId, original.bookId); expect(restored.position, original.position); diff --git a/app/test/models/library_nullable_fields_test.dart b/app/test/models/library_nullable_fields_test.dart index 5b4f598e..b209c3fb 100644 --- a/app/test/models/library_nullable_fields_test.dart +++ b/app/test/models/library_nullable_fields_test.dart @@ -28,6 +28,7 @@ void main() { completedAt: now, lastReadAt: now, ); + final cleared = original .copyWith( clearFileMediaId: true, @@ -44,6 +45,7 @@ void main() { clearLastReadAt: true, ) .toJson(); + for (final field in [ 'file_media_id', 'cover_media_id', @@ -75,6 +77,7 @@ void main() { createdAt: now, updatedAt: now, ); + final cleared = shelf.copyWith( clearDescription: true, clearColorHex: true, @@ -82,16 +85,17 @@ void main() { clearParentShelfId: true, clearSmartQuery: true, ); + expect(cleared.description, isNull); expect(cleared.colorHex, isNull); expect(cleared.icon, isNull); expect(cleared.parentShelfId, isNull); expect(cleared.smartQuery, isNull); expect(shelf.copyWith().parentShelfId, 'parent'); - final tag = Tag(id: 'tag', name: 'Tag', description: 'Description', colorHex: '#123456', createdAt: now); expect(tag.copyWith(clearDescription: true).description, isNull); expect(tag.copyWith().description, 'Description'); + final note = Note( id: 'note', bookId: 'book', @@ -100,8 +104,10 @@ void main() { location: const BookLocation(pageNumber: 3), createdAt: now, ); + expect(note.copyWith(clearLocation: true).location, isNull); expect(note.copyWith().location?.pageNumber, 3); + final annotation = Annotation( id: 'annotation', bookId: 'book', @@ -110,6 +116,7 @@ void main() { location: const BookLocation(pageNumber: 3), createdAt: now, ); + expect(annotation.copyWith(clearNote: true).note, isNull); expect(annotation.copyWith().note, 'Note'); }); diff --git a/app/test/models/note_test.dart b/app/test/models/note_test.dart index 86082651..08fb7707 100644 --- a/app/test/models/note_test.dart +++ b/app/test/models/note_test.dart @@ -15,7 +15,6 @@ void main() { test('preview truncates content longer than 100 characters', () { final longContent = 'A' * 150; final note = buildTestNote(content: longContent); - expect(note.preview.length, 103); // 100 chars + "..." expect(note.preview.endsWith('...'), true); }); @@ -65,7 +64,6 @@ void main() { test('creates copy with updated fields', () { final original = buildTestNote(title: 'Original', content: 'Original content', tags: ['tag1'], isPinned: false); final copy = original.copyWith(title: 'Updated', isPinned: true, tags: ['tag1', 'tag2']); - expect(copy.title, 'Updated'); expect(copy.content, 'Original content'); expect(copy.isPinned, true); @@ -77,6 +75,7 @@ void main() { group('toJson', () { test('serializes all fields including flattened location', () { final now = DateTime(2025, 6, 15); + final note = Note( id: 'note-1', bookId: 'book-1', @@ -90,7 +89,6 @@ void main() { ); final json = note.toJson(); - expect(json['id'], 'note-1'); expect(json['book_id'], 'book-1'); expect(json['title'], 'Test'); @@ -108,7 +106,6 @@ void main() { test('null location fields serialize as null', () { final note = buildTestNote(); final json = note.toJson(); - expect(json['chapter'], isNull); expect(json['chapter_title'], isNull); expect(json['page_number'], isNull); @@ -134,7 +131,6 @@ void main() { }; final note = Note.fromJson(json); - expect(note.id, 'note-1'); expect(note.bookId, 'book-1'); expect(note.title, 'Test'); @@ -193,7 +189,6 @@ void main() { ); final restored = Note.fromJson(original.toJson()); - expect(restored.id, original.id); expect(restored.title, original.title); expect(restored.content, original.content); diff --git a/app/test/models/shelf_icon_test.dart b/app/test/models/shelf_icon_test.dart index 0e59b368..170c0077 100644 --- a/app/test/models/shelf_icon_test.dart +++ b/app/test/models/shelf_icon_test.dart @@ -7,7 +7,6 @@ void main() { final now = DateTime.utc(2026); final base = Shelf(id: 'shelf', name: 'Shelf', createdAt: now, updatedAt: now); const descriptorKeys = ['icon_code_point', 'icon_font_family', 'icon_font_package', 'icon_match_text_direction']; - Map descriptor(Map row) => {for (final key in descriptorKeys) key: row[key]}; test('all available constant icons round trip through shelf rows', () { @@ -29,6 +28,7 @@ void main() { 'icon_font_package': 'other_icons', 'icon_match_text_direction': 1, }; + final restored = shelfRowMapper.fromRow(row); expect(restored.displayIcon, Icons.folder_outlined); final edited = restored.copyWith(name: 'Edited', description: 'Description', icon: restored.icon); @@ -39,6 +39,7 @@ void main() { test('font identity and text direction must match before using a known icon', () { final known = shelfRowMapper.toRow(base.copyWith(icon: Icons.menu_book)); + for (final overrides in [ {'icon_font_family': 'AnotherFamily'}, {'icon_font_package': 'another_package'}, @@ -60,6 +61,7 @@ void main() { 'icon_font_package': 'other_icons', 'icon_match_text_direction': 1, }); + final updated = unknown.copyWith(icon: Icons.favorite_outline); final expected = base.copyWith(icon: Icons.favorite_outline); expect(descriptor(shelfRowMapper.toRow(updated)), descriptor(shelfRowMapper.toRow(expected))); @@ -73,6 +75,7 @@ void main() { 'icon_font_package': 'other_icons', 'icon_match_text_direction': 1, }); + final cleared = unknown.copyWith(clearIcon: true); expect(cleared.icon, isNull); expect(cleared.iconDescriptor, isNull); diff --git a/app/test/opds/browser_test.dart b/app/test/opds/browser_test.dart index 8f0d5b07..0e253279 100644 --- a/app/test/opds/browser_test.dart +++ b/app/test/opds/browser_test.dart @@ -14,9 +14,11 @@ import 'relay_fixture_client.dart'; void main() { final catalog = OpdsCatalog(id: 'c', name: 'Books', uri: Uri.parse('https://books.test/feed')); + Future warmCache() async { SharedPreferences.setMockInitialValues({}); final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('alice'); + await cache.write( cache.capture(catalog, catalog.uri)!, OpdsResponse( @@ -27,15 +29,18 @@ void main() { headers: {'content-type': 'application/opds+json'}, ), ); + return cache; } test('shows cached feed immediately with redirect base while refreshing', () async { final cache = await warmCache(); final response = Completer(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient(cache: cache, clientFactory: () => MockRelayClient((_) => response.future)), ); + final load = browser.load(catalog, catalog.uri); expect(browser.feed?.title, 'Cached'); expect(browser.feed?.navigation.single.uri.toString(), 'https://cdn.test/redirect/next'); @@ -54,12 +59,14 @@ void main() { for (final status in [500, 401, 403]) { test('revalidation HTTP $status ${status == 500 ? 'retains' : 'invalidates'} protected cached feed', () async { final cache = await warmCache(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient( cache: cache, clientFactory: () => MockRelayClient((_) async => http.Response('failure', status)), ), ); + await browser.load(catalog, catalog.uri); expect(browser.error, isNotNull); expect(browser.feed?.title, status == 500 ? 'Cached' : null); @@ -71,9 +78,11 @@ void main() { test('scope A to B to A rejects a late feed and cache write', () async { final cache = await warmCache(); final response = Completer(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient(cache: cache, clientFactory: () => MockRelayClient((_) => response.future)), ); + final load = browser.load(catalog, catalog.uri); await Future.delayed(Duration.zero); cache.setScope('bob'); @@ -87,6 +96,7 @@ void main() { test('one browser authorization failure immediately clears another idle feed', () async { final cache = await warmCache(); + final client = OpdsHttpClient( cache: cache, clientFactory: () => MockRelayClient( @@ -95,6 +105,7 @@ void main() { : http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200), ), ); + final parent = OpdsBrowser(httpClient: client); final details = OpdsBrowser(httpClient: client); await parent.load(catalog, catalog.uri); @@ -118,6 +129,7 @@ void main() { test('scope switch clears an idle feed immediately and unsubscribes on disposal', () async { final cache = await warmCache(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient( cache: cache, @@ -125,6 +137,7 @@ void main() { MockRelayClient((_) async => http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200)), ), ); + await browser.load(catalog, catalog.uri); var notifications = 0; browser.addListener(() => notifications++); @@ -143,11 +156,13 @@ void main() { final cache = await warmCache(); final parentResponse = Completer(); final detailResponse = Completer(); + final client = OpdsHttpClient( cache: cache, clientFactory: () => MockRelayClient((request) => request.url.path == '/details' ? detailResponse.future : parentResponse.future), ); + final parent = OpdsBrowser(httpClient: client); final details = OpdsBrowser(httpClient: client); final parentLoad = parent.load(catalog, catalog.uri); @@ -169,6 +184,7 @@ void main() { test('invalidating another catalog retains the loaded feed', () async { final cache = await warmCache(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient( cache: cache, @@ -176,6 +192,7 @@ void main() { MockRelayClient((_) async => http.Response('{"metadata":{"title":"Private"},"navigation":[]}', 200)), ), ); + await browser.load(catalog, catalog.uri); await cache.invalidateCatalog(OpdsCatalog(id: 'another', name: 'Other', uri: catalog.uri)); expect(browser.feed?.title, 'Private'); @@ -185,12 +202,14 @@ void main() { test('unsupported responses never replace the cached parsed feed', () async { final cache = await warmCache(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient( cache: cache, clientFactory: () => MockRelayClient((_) async => http.Response('Login', 200)), ), ); + await browser.load(catalog, catalog.uri); expect(browser.feed?.title, 'Cached'); expect(browser.error, isNotNull); @@ -204,21 +223,26 @@ void main() { test('root search and OpenSearch remain usable offline with redirected bases', () async { final cache = await warmCache(); + Future store(Uri requested, Uri finalUri, String body) => cache.write( cache.capture(catalog, requested)!, OpdsResponse(uri: finalUri, bytes: Uint8List.fromList(utf8.encode(body)), headers: {}), ); + await store( catalog.uri, Uri.parse('https://cdn.test/base/feed'), '{"metadata":{"title":"Root"},"links":[{"rel":"search","href":"search.xml","type":"application/opensearchdescription+xml"}],"navigation":[]}', ); + await store( Uri.parse('https://cdn.test/base/search.xml'), Uri.parse('https://search.test/redirect/description.xml'), '', ); + var requests = 0; + final browser = OpdsBrowser( httpClient: OpdsHttpClient( cache: cache, @@ -228,6 +252,7 @@ void main() { }), ), ); + await browser.load(catalog, catalog.uri.resolve('section')); expect((await browser.search('some books')).toString(), 'https://search.test/redirect/results?q=some%20books'); expect(requests, 1); @@ -236,6 +261,7 @@ void main() { test('authorization failure resolving search clears the previous feed', () async { final cache = await warmCache(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient( cache: cache, @@ -246,6 +272,7 @@ void main() { ), ), ); + // Remove the cached root so resolving search must ask the server. await cache.remove(cache.capture(catalog, catalog.uri)!); await browser.load(catalog, catalog.uri.resolve('section')); @@ -255,16 +282,22 @@ void main() { expect(browser.authorizationFailed, isTrue); browser.dispose(); }); + test('late feed results cannot replace the current navigation', () async { final oldResponse = Completer(); + final browser = OpdsBrowser( httpClient: OpdsHttpClient( clientFactory: () => MockRelayClient((request) async { - if (request.url.path == '/old') return oldResponse.future; + if (request.url.path == '/old') { + return oldResponse.future; + } + return http.Response('{"metadata":{"title":"New"},"navigation":[]}', 200); }), ), ); + final old = browser.load(catalog, Uri.parse('https://books.test/old')); await Future.delayed(Duration.zero); await browser.load(catalog, Uri.parse('https://books.test/new')); @@ -287,6 +320,7 @@ void main() { }), ), ); + await browser.load(catalog, Uri.parse('https://books.test/section')); final uri = await browser.search('a & b'); expect(uri.queryParameters['query'], 'a & b'); diff --git a/app/test/opds/catalog_store_test.dart b/app/test/opds/catalog_store_test.dart index d815c86a..e5fb8218 100644 --- a/app/test/opds/catalog_store_test.dart +++ b/app/test/opds/catalog_store_test.dart @@ -11,8 +11,10 @@ import 'package:shared_preferences_platform_interface/shared_preferences_platfor class MemorySecrets implements OpdsSecretStorage { final values = {}; + @override Future read(String key) async => values[key]; + @override Future write(String key, String value) async { values[key] = value; @@ -32,8 +34,14 @@ class _FailingPreferences extends InMemorySharedPreferencesStore { @override Future setValue(String valueType, String key, Object value) async { - if (writeError != null) throw writeError!; - if (rejectWrites) return false; + if (writeError != null) { + throw writeError!; + } + + if (rejectWrites) { + return false; + } + return super.setValue(valueType, key, value); } } @@ -43,7 +51,10 @@ class _FailingSecrets extends MemorySecrets { @override Future write(String key, String value) async { - if (writeError != null) throw writeError!; + if (writeError != null) { + throw writeError!; + } + await super.write(key, value); } } @@ -67,11 +78,13 @@ void main() { final secrets = MemorySecrets(); final store = OpdsCatalogStore(prefs, secrets: secrets); final catalog = OpdsCatalog(id: 'one', name: 'Private', uri: Uri.parse('https://books.test/feed')); + await store.save( 'server--alice', catalog, credentials: const OpdsCredentials(username: 'alice', password: 'secret'), ); + expect(store.load('server--alice').single.name, 'Private'); expect(store.load('server--bob'), isEmpty); expect(store.load('local--guest'), isEmpty); @@ -86,11 +99,13 @@ void main() { test('changing a catalog origin clears saved credentials', () async { SharedPreferences.setMockInitialValues({}); final store = OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()); + await store.save( 'guest', OpdsCatalog(id: 'one', name: 'One', uri: Uri.parse('https://one.test')), credentials: const OpdsCredentials(username: 'u', password: 'p'), ); + await store.save('guest', OpdsCatalog(id: 'one', name: 'Two', uri: Uri.parse('https://two.test'))); expect(await store.credentials('guest', 'one'), isNull); }); @@ -100,11 +115,13 @@ void main() { final prefs = await SharedPreferences.getInstance(); final secrets = MemorySecrets(); final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( scope, originA, credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), ); + expect((jsonDecode(secrets.values[secretKey]!) as Map)['origin'], originA.uri.origin); await prefs.setString(catalogKey, jsonEncode([originB.toJson()])); expect(await OpdsCatalogStore(prefs, secrets: secrets).credentials(scope, 'one'), isNull); @@ -116,6 +133,7 @@ void main() { SharedPreferences.setMockInitialValues({ catalogKey: jsonEncode([originA.toJson()]), }); + final prefs = await SharedPreferences.getInstance(); final secrets = MemorySecrets()..values[secretKey] = jsonEncode({'username': 'alice', 'password': 'unbound'}); expect(await OpdsCatalogStore(prefs, secrets: secrets).credentials(scope, 'one'), isNull); @@ -123,16 +141,19 @@ void main() { for (final throwsOnWrite in [false, true]) { final mode = throwsOnWrite ? 'throws' : 'returns false'; + test('save restores catalog cache and old credentials when preferences $mode', () async { final platform = _FailingPreferences(); final prefs = await failingPreferences(platform); final secrets = MemorySecrets(); final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( scope, originA, credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), ); + final originalJson = prefs.getString(catalogKey); final error = StateError('Preference save failed'); platform.rejectWrites = !throwsOnWrite; @@ -146,6 +167,7 @@ void main() { ), throwsOnWrite ? throwsA(same(error)) : throwsA(isA()), ); + expect(prefs.getString(catalogKey), originalJson); final reloaded = OpdsCatalogStore(prefs, secrets: secrets); expect(reloaded.load(scope).single.uri, originA.uri); @@ -163,11 +185,13 @@ void main() { final prefs = await failingPreferences(platform); final secrets = MemorySecrets(); final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( scope, originA, credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), ); + final originalJson = prefs.getString(catalogKey); final error = StateError('Preference removal failed'); platform.rejectWrites = !throwsOnWrite; @@ -177,6 +201,7 @@ void main() { store.remove(scope, 'one'), throwsOnWrite ? throwsA(same(error)) : throwsA(isA()), ); + expect(prefs.getString(catalogKey), originalJson); final reloaded = OpdsCatalogStore(prefs, secrets: secrets); expect(reloaded.load(scope).single.uri, originA.uri); @@ -190,6 +215,7 @@ void main() { final store = OpdsCatalogStore(prefs, secrets: secrets); platform.rejectWrites = !throwsOnWrite; platform.writeError = throwsOnWrite ? StateError('Preference save failed') : null; + await expectLater( store.save( scope, @@ -198,6 +224,7 @@ void main() { ), throwsA(anything), ); + expect(prefs.containsKey(catalogKey), isFalse); expect(store.load(scope), isEmpty); expect(secrets.values, isEmpty); @@ -209,11 +236,13 @@ void main() { final prefs = await failingPreferences(platform); final secrets = _FailingSecrets(); final store = OpdsCatalogStore(prefs, secrets: secrets); + await store.save( scope, originA, credentials: const OpdsCredentials(username: 'alice', password: 'origin-a'), ); + final originalJson = prefs.getString(catalogKey); final originalError = StateError('Preference save failed'); platform.writeError = originalError; diff --git a/app/test/opds/catalogs_page_test.dart b/app/test/opds/catalogs_page_test.dart index b32e0ec0..70c84f67 100644 --- a/app/test/opds/catalogs_page_test.dart +++ b/app/test/opds/catalogs_page_test.dart @@ -32,19 +32,28 @@ class _WidgetCatalogStore extends OpdsCatalogStore { @override List load(String scope) { - if (loadFailure != null) throw OpdsException(loadFailure!); + if (loadFailure != null) { + throw OpdsException(loadFailure!); + } + return super.load(scope); } @override Future save(String scope, OpdsCatalog catalog, {OpdsCredentials? credentials, bool clearCredentials = false}) { - if (saveFailure != null) throw OpdsException(saveFailure!); + if (saveFailure != null) { + throw OpdsException(saveFailure!); + } + return super.save(scope, catalog, credentials: credentials, clearCredentials: clearCredentials); } @override Future remove(String scope, String id) { - if (removeFailure != null) throw OpdsException(removeFailure!); + if (removeFailure != null) { + throw OpdsException(removeFailure!); + } + return super.remove(scope, id); } } @@ -73,14 +82,21 @@ class _CatalogPageHarness { addTearDown(tester.view.resetDevicePixelRatio); SharedPreferences.setMockInitialValues({}); final store = _WidgetCatalogStore(await SharedPreferences.getInstance()); + await store.save( 'local--guest', OpdsCatalog(id: 'one', name: 'My catalog', uri: Uri.parse('https://books.test/feed')), ); + final cache = prepareCache == null ? null : OpdsResourceCache(await SharedPreferences.getInstance()); final catalogs = OpdsCatalogs(store, cache: cache)..setScope('local--guest'); - if (cache != null) await prepareCache!(cache, catalogs.find('one')!); + + if (cache != null) { + await prepareCache!(cache, catalogs.find('one')!); + } + final harness = _CatalogPageHarness(store, catalogs); + final gateway = OpdsHttpClient( cache: cache, clientFactory: () => MockRelayClient((request) async { @@ -88,6 +104,7 @@ class _CatalogPageHarness { return respond(request.url); }), ); + harness.downloads = OpdsDownloads( httpClient: gateway, captureImport: () { @@ -95,11 +112,13 @@ class _CatalogPageHarness { throw StateError('This widget test must not start an import'); }, ); + Widget page(GoRouterState state) => CatalogsPage( catalogId: state.pathParameters['catalogId'], feedUri: state.uri.queryParameters['feed'] == null ? null : Uri.parse(state.uri.queryParameters['feed']!), query: state.uri.queryParameters['q'] ?? '', ); + harness.router = GoRouter( initialLocation: initialLocation, routes: [ @@ -112,6 +131,7 @@ class _CatalogPageHarness { ), ], ); + await tester.pumpWidget( MultiProvider( providers: [ @@ -126,12 +146,14 @@ class _CatalogPageHarness { ), ), ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); harness.router.dispose(); harness.catalogs.dispose(); harness.downloads.dispose(); }); + await _settleNetwork(tester); return harness; } @@ -143,6 +165,7 @@ Future _settleNetwork(WidgetTester tester) async { await tester.pumpAndSettle(); await tester.runAsync(() => Future.delayed(Duration.zero)); } + await tester.pumpAndSettle(); } @@ -183,6 +206,7 @@ void main() { ), initialLocation: '/library/catalogs', ); + await tester.tap(find.text('My catalog')); await _settleNetwork(tester); await tester.tap(find.text('Choose an edition')); @@ -215,6 +239,7 @@ void main() { navigation: List.generate(30, (index) => {'title': 'Edition group $index', 'href': '/editions'}), ), ); + await tester.tap(find.byTooltip('List view')); await _settleNetwork(tester); await tester.drag(find.byType(CustomScrollView), const Offset(0, -450)); @@ -248,6 +273,7 @@ void main() { queryParameters: {'feed': 'https://books.test/editions', 'q': 'Austen'}, ).toString(), ); + expect(find.text('Available editions'), findsOneWidget); await tester.tap(find.byTooltip('Back')); await _settleNetwork(tester); @@ -260,6 +286,7 @@ void main() { testWidgets('web route restoration preserves search and one-step header back', (tester) async { const query = 'tea & coffee/ž'; + final harness = await _CatalogPageHarness.mount( tester, (uri) => uri.path == '/search' @@ -278,6 +305,7 @@ void main() { ], ), ); + await tester.enterText(find.byType(TextField), query); await tester.tap(find.byTooltip('Search catalog')); await _settleNetwork(tester); @@ -308,6 +336,7 @@ void main() { Future seedCache(OpdsResourceCache cache, OpdsCatalog catalog) async { final response = _feedResponse('Saved books', publications: [_book()]); + await cache.write( cache.capture(catalog, catalog.uri)!, OpdsResponse(uri: catalog.uri, bytes: response.bodyBytes, headers: response.headers), @@ -333,17 +362,21 @@ void main() { final initial = Completer(); final refreshed = Completer(); var requests = 0; + await _CatalogPageHarness.mount( tester, (_) => ++requests == 1 ? initial.future : refreshed.future, prepareCache: seedCache, ); + tester.view.physicalSize = Size(width, 1000); await tester.pumpAndSettle(); + if (listView) { await tester.tap(find.byIcon(Icons.view_list)); await tester.pumpAndSettle(); } + final bookPosition = tester.getTopLeft(find.text('A book')); final contentPosition = tester.getTopLeft(find.byType(CustomScrollView)); initial.complete(_feedResponse('Saved books', publications: [_book()])); @@ -370,6 +403,7 @@ void main() { testWidgets('details deep link renders cached publication before refresh and survives offline', (tester) async { final response = Completer(); + await _CatalogPageHarness.mount( tester, (_) => response.future, @@ -379,6 +413,7 @@ void main() { queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, ).toString(), ); + expect(find.text('A book'), findsOneWidget); expect(find.text('Description'), findsOneWidget); response.completeError(http.ClientException('Offline')); @@ -390,6 +425,7 @@ void main() { testWidgets('authorization rejection removes cached details', (tester) async { final response = Completer(); + await _CatalogPageHarness.mount( tester, (_) => response.future, @@ -399,6 +435,7 @@ void main() { queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, ).toString(), ); + expect(find.text('A book'), findsOneWidget); response.complete(http.Response('Unauthorized', 401)); await _settleNetwork(tester); @@ -409,6 +446,7 @@ void main() { testWidgets('cached standalone metadata appears before the source refresh completes', (tester) async { final response = Completer(); final detailUri = Uri.parse('https://books.test/detail'); + await _CatalogPageHarness.mount( tester, (_) => response.future, @@ -424,10 +462,12 @@ void main() { }, ], ); + await cache.write( cache.capture(catalog, catalog.uri)!, OpdsResponse(uri: catalog.uri, bytes: source.bodyBytes, headers: source.headers), ); + await cache.write( cache.capture(catalog, detailUri)!, OpdsResponse( @@ -448,6 +488,7 @@ void main() { queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, ).toString(), ); + expect(find.text('Complete title'), findsOneWidget); expect(find.text('Cached description'), findsOneWidget); response.completeError(http.ClientException('Offline')); @@ -474,6 +515,7 @@ void main() { ), prepareCache: (_, _) async {}, ); + await tester.tap(find.text('A book')); await _settleNetwork(tester); expect(find.text('Book unavailable'), findsOneWidget); @@ -487,6 +529,7 @@ void main() { testWidgets('late parent authorization failure clears already-resolved book details', (tester) async { final parentResponse = Completer(); var requests = 0; + await _CatalogPageHarness.mount( tester, (_) => ++requests == 1 ? parentResponse.future : _feedResponse('Books', publications: [_book()]), @@ -496,6 +539,7 @@ void main() { queryParameters: {'feed': 'https://books.test/feed', 'publication': 'book-one'}, ).toString(), ); + expect(find.text('A book'), findsOneWidget); expect(find.text('Add to library'), findsOneWidget); parentResponse.complete(http.Response('Unauthorized', 401)); @@ -519,6 +563,7 @@ void main() { testWidgets('navigation does not substitute the clicked label for the feed title', (tester) async { final response = Completer(); const title = 'Austen & Brontë / novels'; + final harness = await _CatalogPageHarness.mount( tester, (uri) => uri.path == '/feed' @@ -530,6 +575,7 @@ void main() { ) : response.future, ); + await tester.tap(find.text(title)); await _settleNetwork(tester); expect(find.text(title), findsNothing); @@ -543,6 +589,7 @@ void main() { testWidgets('a direct feed URL ignores outdated title hints', (tester) async { final response = Completer(); + await _CatalogPageHarness.mount( tester, (_) => response.future, @@ -551,6 +598,7 @@ void main() { queryParameters: {'feed': 'https://books.test/authors', 'title': 'Austen & Brontë / novels'}, ).toString(), ); + expect(find.text('Austen & Brontë / novels'), findsNothing); response.complete(_feedResponse('Author novels')); await _settleNetwork(tester); @@ -561,6 +609,7 @@ void main() { testWidgets('feed heading stays fixed while $view scrolls and reloads', (tester) async { final refreshed = Completer(); var requests = 0; + final response = _feedResponse( 'All books', publications: view == 'navigation' @@ -576,11 +625,14 @@ void main() { ? List.generate(40, (index) => {'title': 'Section $index', 'href': '/section-$index'}) : [], ); + await _CatalogPageHarness.mount(tester, (_) => ++requests == 2 ? refreshed.future : response); + if (view == 'list') { await tester.tap(find.byIcon(Icons.view_list)); await tester.pumpAndSettle(); } + final headingPosition = tester.getTopLeft(find.text('All books')); await tester.drag(find.byType(CustomScrollView), const Offset(0, -700)); await tester.pumpAndSettle(); @@ -622,34 +674,41 @@ void main() { expect(find.byKey(const Key('opds-name')), findsOneWidget); expect(tester.takeException(), isNull); }); + testWidgets('the last mobile source scrolls above the Add catalog FAB', (tester) async { final harness = await _CatalogPageHarness.mount( tester, (_) => _feedResponse('Books'), initialLocation: '/library/catalogs', ); + for (var index = 0; index < 12; index++) { await harness.store.save( 'local--guest', OpdsCatalog(id: 'source-$index', name: 'Source $index', uri: Uri.parse('https://books.test/$index')), ); } + harness.catalogs.reload(); tester.view.physicalSize = const Size(360, 640); await _settleNetwork(tester); await tester.drag(find.byType(ListView), const Offset(0, -2000)); await tester.pumpAndSettle(); + final scrollable = tester.state( find.descendant(of: find.byType(ListView), matching: find.byType(Scrollable)), ); + // Lazy rows can revise the estimated extent during a long drag. scrollable.position.jumpTo(scrollable.position.maxScrollExtent); await tester.pumpAndSettle(); expect(tester.widget(find.byType(CatalogSourceTile).last).catalog.name, 'Source 11'); + expect( tester.getBottomRight(find.byType(CatalogSourceTile).last).dy, lessThan(tester.getTopLeft(find.byType(FloatingActionButton)).dy), ); + expect(find.byTooltip('Catalog options'), findsNothing); await tester.drag(find.byType(CatalogSourceTile).last, const Offset(-250, 0)); await tester.pumpAndSettle(); @@ -663,6 +722,7 @@ void main() { (_) => _feedResponse('Books'), initialLocation: '/library/catalogs', ); + tester.view.physicalSize = const Size(424, 951); await _settleNetwork(tester); expect(find.byTooltip('Catalog options'), findsNothing); @@ -681,7 +741,6 @@ void main() { await tester.tap(find.text('Save')); await _settleNetwork(tester); expect(find.text('Renamed catalog'), findsOneWidget); - await tester.drag(find.byType(CatalogSourceTile), const Offset(-300, 0)); await tester.pumpAndSettle(); expect(find.text('Remove catalog'), findsOneWidget); @@ -690,7 +749,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Renamed catalog'), findsOneWidget); expect(find.text('Delete').hitTestable(), findsNothing); - await tester.tap(find.text('Renamed catalog')); await _settleNetwork(tester); expect(find.text('Books'), findsOneWidget); @@ -711,9 +769,11 @@ void main() { (_) => _feedResponse('Books'), initialLocation: '/library/catalogs', ); + tester.view.physicalSize = const Size(424, 951); await _settleNetwork(tester); final originalPosition = tester.getTopLeft(find.text('My catalog')); + for (final distance in [-60.0, 60.0]) { await tester.timedDrag(find.byType(CatalogSourceTile), Offset(distance, 0), const Duration(milliseconds: 500)); await tester.pumpAndSettle(); @@ -722,6 +782,7 @@ void main() { expect(harness.catalogs.catalogs, hasLength(1)); expect(harness.requests, isEmpty); } + await tester.tap(find.text('My catalog')); await _settleNetwork(tester); expect(find.text('Books'), findsOneWidget); @@ -733,6 +794,7 @@ void main() { tester, (_) => _feedResponse('Pride and Prejudice by Jane Austen', publications: [_book()]), ); + tester.view.physicalSize = const Size(424, 951); await _settleNetwork(tester); final heading = tester.getRect(find.text('My catalog')); @@ -754,6 +816,7 @@ void main() { expect(find.byType(BottomSheet), findsOneWidget); expect(tester.takeException(), isNull); }); + testWidgets('catalog sources use flat rows and edit in a bottom sheet', (tester) async { await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books'), initialLocation: '/library/catalogs'); expect(find.byType(GridView), findsNothing); @@ -776,6 +839,7 @@ void main() { testWidgets('a direct book URL resolves metadata and missing publications can recover', (tester) async { var available = false; + final location = Uri( path: '/library/catalogs/one/book', queryParameters: { @@ -784,6 +848,7 @@ void main() { 'q': 'tea & coffee', }, ).toString(); + final harness = await _CatalogPageHarness.mount(tester, (uri) { if (uri.path == '/detail') { return http.Response( @@ -801,6 +866,7 @@ void main() { headers: {'content-type': 'application/opds-publication+json'}, ); } + return _feedResponse( 'Search results', publications: available @@ -815,15 +881,18 @@ void main() { : [], ); }, initialLocation: location); + expect(find.text('Book unavailable'), findsOneWidget); available = true; await tester.tap(find.text('Retry')); await _settleNetwork(tester); + expect( find.text('Full title'), findsOneWidget, - reason: '${harness.requests}; ${tester.widgetList(find.byType(Text)).map((t) => t.data).join(' | ')}', + reason: '${harness.requests}; ${tester.widgetList(find.byType(Text)).map((item) => item.data).join(' | ')}', ); + expect(find.text('Complete description.'), findsOneWidget); expect(harness.requests.last.path, '/detail'); expect(find.text('Add to library'), findsOneWidget); @@ -850,13 +919,16 @@ void main() { ], ), ); + await tester.tap(find.byIcon(Icons.view_list)); await tester.pumpAndSettle(); + await tester.scrollUntilVisible( find.text('Book 20'), 500, scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, ); + await tester.pumpAndSettle(); final y = tester.getTopLeft(find.text('Book 20')).dy; final requests = harness.requests.length; @@ -899,6 +971,7 @@ void main() { ], ); } + return _feedResponse( 'Books', publications: [ @@ -911,6 +984,7 @@ void main() { ], ); }); + await tester.tap(find.text('Selected book')); await _settleNetwork(tester); expect(find.text('Selected book'), findsOneWidget); @@ -925,11 +999,13 @@ void main() { testWidgets('downloads retry with refreshed catalog settings after a catalog reload', (tester) async { final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books', publications: [_book()])); final catalog = harness.catalogs.catalogs.single; + await harness.downloads.start( catalog, OpdsPublication(id: 'test', title: 'Failed book'), OpdsLink(uri: Uri.parse('https://books.test/book.epub'), type: 'application/epub+zip', rels: ['download']), ); + expect(harness.importAttempts, 1); harness.catalogs.reload(); await _settleNetwork(tester); @@ -945,6 +1021,7 @@ void main() { final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books')); tester.view.physicalSize = const Size(360, 640); addTearDown(tester.view.resetViewInsets); + for (var index = 0; index < 3; index++) { await harness.downloads.start( harness.catalogs.catalogs.single, @@ -952,6 +1029,7 @@ void main() { OpdsLink(uri: Uri.parse('https://books.test/book.epub'), type: 'application/epub+zip', rels: ['download']), ); } + await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Downloads')); await tester.pumpAndSettle(); @@ -973,8 +1051,14 @@ void main() { testWidgets('facet choices and collection View all links navigate to their feeds', (tester) async { final harness = await _CatalogPageHarness.mount(tester, (uri) { - if (uri.path == '/filtered') return _feedResponse('Filtered books'); - if (uri.path == '/collection') return _feedResponse('Full collection'); + if (uri.path == '/filtered') { + return _feedResponse('Filtered books'); + } + + if (uri.path == '/collection') { + return _feedResponse('Full collection'); + } + return http.Response( jsonEncode({ 'metadata': {'title': 'Browse books'}, @@ -999,6 +1083,7 @@ void main() { 200, ); }); + expect(find.text('Language:'), findsOneWidget); expect(find.text('Featured'), findsOneWidget); await tester.tap(find.widgetWithText(ActionChip, 'English')); @@ -1019,9 +1104,11 @@ void main() { const query = 'tea & coffee/ž'; final searchUri = Uri.https('books.test', '/search', {'query': query}); final pageTwoUri = searchUri.replace(queryParameters: {'query': query, 'page': '2'}); + final harness = await _CatalogPageHarness.mount(tester, (uri) { if (uri.path == '/search') { final pageTwo = uri.queryParameters['page'] == '2'; + return _feedResponse( pageTwo ? 'Search page two' : 'Search results', links: [ @@ -1029,6 +1116,7 @@ void main() { ], ); } + return _feedResponse( 'Catalog home feed', links: [ @@ -1036,6 +1124,7 @@ void main() { ], ); }); + final homeRoute = harness.router.routeInformationProvider.value.uri; await tester.enterText(find.byType(TextField), query); await tester.tap(find.byTooltip('Search catalog')); @@ -1079,6 +1168,7 @@ void main() { (_) => _feedResponse('Books'), initialLocation: '/library/catalogs', ); + harness.store.saveFailure = 'Catalog save failed.'; await tester.tap(find.byTooltip('Catalog options')); await tester.pumpAndSettle(); @@ -1095,7 +1185,6 @@ void main() { await _settleNetwork(tester); expect(find.byKey(const Key('opds-name')), findsNothing); expect(find.text('Renamed catalog'), findsOneWidget); - harness.store.removeFailure = 'Catalog removal failed.'; await tester.tap(find.byTooltip('Catalog options')); await tester.pumpAndSettle(); @@ -1120,17 +1209,24 @@ void main() { testWidgets('a failed feed can retry to empty results and refresh to books', (tester) async { var attempt = 0; + final harness = await _CatalogPageHarness.mount(tester, (_) { attempt++; - if (attempt == 1) return http.Response('Unavailable', 503); + + if (attempt == 1) { + return http.Response('Unavailable', 503); + } + return _feedResponse('Books', publications: attempt == 2 ? [] : [_book()]); }); + expect( find.textContaining('HTTP 503'), findsOneWidget, reason: 'requests=${harness.requests}; texts=${tester.widgetList(find.byType(Text)).map((text) => text.data).join(' | ')}', ); + await tester.tap(find.text('Retry')); await _settleNetwork(tester); expect(find.textContaining('HTTP 503'), findsNothing); @@ -1145,12 +1241,14 @@ void main() { testWidgets('open publication details cannot download after catalog or account changes', (tester) async { final harness = await _CatalogPageHarness.mount(tester, (_) => _feedResponse('Books', publications: [_book()])); + for (final changeAccount in [false, true]) { await tester.tap(find.text('A book')); await _settleNetwork(tester); await tester.tap(find.text('Add to library')); await tester.pumpAndSettle(); expect(find.text('Download EPUB'), findsOneWidget); + if (changeAccount) { harness.catalogs.setScope('local--other'); } else { @@ -1158,6 +1256,7 @@ void main() { OpdsCatalog(id: 'one', name: 'Changed catalog', uri: Uri.parse('https://other-books.test/feed')), ); } + await _settleNetwork(tester); await tester.tap(find.text('Download EPUB')); await _settleNetwork(tester); @@ -1171,6 +1270,7 @@ void main() { await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); } + expect(tester.takeException(), isNull); }); @@ -1180,6 +1280,7 @@ void main() { (_) => _feedResponse('Books'), initialLocation: '/library/catalogs', ); + await tester.tap(find.byTooltip('Catalog options')); await tester.pumpAndSettle(); await tester.tap(find.text('Edit')); @@ -1204,9 +1305,12 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) ..setScope('local--guest'); + final downloads = OpdsDownloads(captureImport: () => throw StateError('unused')); + final gateway = OpdsHttpClient( clientFactory: () => MockRelayClient( (_) async => http.Response( @@ -1215,6 +1319,7 @@ void main() { ), ), ); + final router = GoRouter( initialLocation: '/library/catalogs', routes: [ @@ -1230,6 +1335,7 @@ void main() { ), ], ); + await tester.pumpWidget( MultiProvider( providers: [ @@ -1243,13 +1349,16 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.text('No catalogs yet'), findsOneWidget); + if (width >= 840) { expect(find.text('Catalogs'), findsNothing); expect(find.byKey(const Key('catalog-header-divider')), findsNothing); expect(find.byType(LibraryToolbar), findsOneWidget); } + await tester.tap(find.text('Add catalog').first); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('opds-name')), 'My catalog'); @@ -1262,12 +1371,14 @@ void main() { await tester.pump(); await tester.runAsync(() => Future.delayed(Duration.zero)); await tester.pumpAndSettle(); + expect( find.text('A book'), findsOneWidget, reason: '${tester.widgetList(find.byType(Text)).map((widget) => widget.data).join(' | ')}; progress=${find.byType(CircularProgressIndicator).evaluate().length}', ); + expect(find.text('An author'), findsOneWidget); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); diff --git a/app/test/opds/catalogs_provider_test.dart b/app/test/opds/catalogs_provider_test.dart index 39aa9173..dd2ed5ae 100644 --- a/app/test/opds/catalogs_provider_test.dart +++ b/app/test/opds/catalogs_provider_test.dart @@ -11,6 +11,7 @@ import 'catalog_store_test.dart' show MemorySecrets; class DelayedSecrets extends MemorySecrets { Completer? nextRead; + @override Future read(String key) { final pending = nextRead; @@ -23,20 +24,25 @@ void main() { test('credential lookups are invalidated by edits and scope changes', () async { SharedPreferences.setMockInitialValues({}); final secrets = DelayedSecrets(); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: secrets)) ..setScope('guest'); + await catalogs.save( OpdsCatalog(id: 'c', name: 'One', uri: Uri.parse('https://one.test')), credentials: const OpdsCredentials(username: 'one', password: 'secret'), ); + final oldSecret = secrets.values.values.single; final pending = secrets.nextRead = Completer(); final lookup = catalogs.credentials('c'); final expectation = expectLater(lookup, throwsA(isA())); + await catalogs.save( OpdsCatalog(id: 'c', name: 'Two', uri: Uri.parse('https://two.test')), credentials: const OpdsCredentials(username: 'two', password: 'secret'), ); + pending.complete(oldSecret); await expectation; final pendingScope = secrets.nextRead = Completer(); diff --git a/app/test/opds/cover_test.dart b/app/test/opds/cover_test.dart index 922afdd2..a3886eda 100644 --- a/app/test/opds/cover_test.dart +++ b/app/test/opds/cover_test.dart @@ -53,6 +53,7 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final cache = OpdsResourceCache(prefs)..setScope('guest'); + await cache.write( cache.capture(_catalog, _coverUri)!, OpdsResponse( @@ -63,6 +64,7 @@ void main() { headers: {'content-type': 'image/png'}, ), ); + final client = _PendingCoverClient(cache: OpdsResourceCache(prefs)..setScope('guest')); await _mount(tester, client, theme: AppTheme.eink, size: const Size(180, 270), uri: _coverUri); await tester.pumpAndSettle(); @@ -79,6 +81,7 @@ void main() { SharedPreferences.setMockInitialValues({}); final cache = OpdsResourceCache(await SharedPreferences.getInstance())..setScope('guest'); final client = _PendingCoverClient(cache: cache); + await _mount( tester, client, @@ -86,23 +89,23 @@ void main() { size: const Size(180, 270), uri: Uri.parse('https://user:secret@books.test/cover.png'), ); + await tester.pumpAndSettle(); expect(find.byType(CoverLoadingPlaceholder), findsNothing); expect(find.byIcon(Icons.menu_book), findsOneWidget); expect(client.requests, 0); expect(tester.takeException(), isNull); }); + for (final (name, theme) in [('light', AppTheme.light), ('dark', AppTheme.dark), ('eink', AppTheme.eink)]) { for (final size in [const Size(60, 90), const Size(180, 270)]) { testWidgets('$name $size shares library loading state until request fails', (tester) async { final client = _PendingCoverClient(); await _mount(tester, client, theme: theme, size: size, uri: _coverUri); - expect(find.byType(CoverLoadingPlaceholder), findsOneWidget); expect(find.byIcon(Icons.auto_stories_rounded), findsOneWidget); expect(find.text('Loading…'), size.width < 72 ? findsNothing : findsOneWidget); expect(tester.getSize(find.byType(OpdsCover)), size); - client.response.completeError(const OpdsException('Artwork unavailable')); await tester.pumpAndSettle(); expect(find.byType(CoverLoadingPlaceholder), findsNothing); @@ -135,6 +138,7 @@ void main() { headers: {'content-type': 'image/png'}, ), ); + await tester.pumpAndSettle(); final image = tester.widget(find.byType(Image)); await tester.runAsync(() => precacheImage(image.image, tester.element(find.byType(OpdsCover)))); diff --git a/app/test/opds/downloads_test.dart b/app/test/opds/downloads_test.dart index 2180f27f..c58f6ef4 100644 --- a/app/test/opds/downloads_test.dart +++ b/app/test/opds/downloads_test.dart @@ -15,12 +15,15 @@ import 'relay_fixture_client.dart'; void main() { final catalog = OpdsCatalog(id: 'c', name: 'Books', uri: Uri.parse('https://books.test/feed')); + final link = OpdsLink( uri: Uri.parse('https://books.test/book.epub'), type: 'application/epub+zip', rels: ['http://opds-spec.org/acquisition/open-access'], ); + final publication = OpdsPublication(id: 'p', title: 'Catalog title', authors: ['Catalog author'], links: [link]); + BookImportResult result() => const BookImportResult( bookId: 'imported', title: 'book', @@ -32,6 +35,7 @@ void main() { test('network failure offers manual recovery without processing or committing a book', () async { var processes = 0; + final downloads = OpdsDownloads( httpClient: OpdsHttpClient( clientFactory: () => MockRelayClient((_) async { @@ -48,6 +52,7 @@ void main() { commit: (_, _) async => throw StateError('must not commit'), ), ); + await downloads.start(catalog, publication, link); expect(downloads.jobs.single.status, OpdsDownloadStatus.failed); expect(downloads.jobs.single.networkFailure, isTrue); @@ -57,11 +62,13 @@ void main() { test('rejects a disguised login page before importing a text download', () async { var processed = false; + final textLink = OpdsLink( uri: Uri.parse('https://books.test/book.txt'), type: 'text/plain', rels: ['http://opds-spec.org/acquisition'], ); + final downloads = OpdsDownloads( httpClient: OpdsHttpClient( clientFactory: () => @@ -77,6 +84,7 @@ void main() { commit: (_, _) async => throw StateError('unused'), ), ); + await downloads.start(catalog, publication, textLink); expect(processed, isFalse); expect(downloads.jobs.single.status, OpdsDownloadStatus.failed); @@ -86,6 +94,7 @@ void main() { test('commits a downloaded book and fills missing embedded metadata', () async { BookImportResult? imported; + final downloads = OpdsDownloads( httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), captureImport: () => BookImportSession( @@ -98,6 +107,7 @@ void main() { }, ), ); + await downloads.start(catalog, publication, link); expect(imported?.title, 'Catalog title'); expect(imported?.author, 'Catalog author'); @@ -108,6 +118,7 @@ void main() { test('the final commit phase does not offer cancellation', () async { final committing = Completer(); final finish = Completer(); + final downloads = OpdsDownloads( httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), captureImport: () => BookImportSession( @@ -120,6 +131,7 @@ void main() { }, ), ); + final operation = downloads.start(catalog, publication, link); await committing.future; expect(downloads.jobs.single.isCancellable, isFalse); @@ -135,6 +147,7 @@ void main() { final started = Completer(); final deleted = []; var commits = 0; + final downloads = OpdsDownloads( httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), captureImport: () => BookImportSession( @@ -150,6 +163,7 @@ void main() { }, ), ); + final operation = downloads.start(catalog, publication, link); await started.future; downloads.reset(); @@ -165,6 +179,7 @@ void main() { final processing = Completer(); var requests = 0; final deleted = []; + final downloads = OpdsDownloads( httpClient: OpdsHttpClient( clientFactory: () => MockRelayClient((_) async { @@ -179,6 +194,7 @@ void main() { commit: (_, _) async => throw StateError('commit failed'), ), ); + final first = downloads.start(catalog, publication, link); final second = downloads.start(catalog, publication, link); processing.complete(result()); diff --git a/app/test/opds/http_client_test.dart b/app/test/opds/http_client_test.dart index ceebbcbe..697c9311 100644 --- a/app/test/opds/http_client_test.dart +++ b/app/test/opds/http_client_test.dart @@ -14,6 +14,7 @@ void main() { test('uses the currently selected server for subsequent requests', () async { final seen = []; var server = 'https://papyrus.test'; + final gateway = OpdsHttpClient( apiConfig: () => PapyrusApiConfig(serverBaseUri: Uri.parse(server)), clientFactory: () => MockClient((request) async { @@ -21,6 +22,7 @@ void main() { return http.Response('book', 200, headers: {'x-opds-url': 'https://cdn.test/book.epub'}); }), ); + final response = await gateway.get(catalog, catalog.uri, credentials: credentials); server = 'https://custom.test'; await gateway.get(catalog, catalog.uri); @@ -34,6 +36,7 @@ void main() { test('reports authentication errors without exposing credentials', () async { final gateway = OpdsHttpClient(clientFactory: () => MockClient((_) async => http.Response('private', 401))); + await expectLater( gateway.get(catalog, catalog.uri, credentials: credentials), throwsA(isA().having((e) => e.message, 'message', contains('credentials'))), @@ -46,8 +49,10 @@ void main() { throw http.ClientException('Failed to fetch'); }), ); + await expectLater(gateway.get(catalog, catalog.uri), throwsA(isA())); final denied = OpdsHttpClient(clientFactory: () => MockClient((_) async => http.Response('', 403))); + await expectLater( denied.get(catalog, catalog.uri), throwsA(isA().having((error) => error is OpdsConnectionException, 'connection failure', isFalse)), @@ -56,6 +61,7 @@ void main() { test('rejects unsafe and credential-bearing resource URLs', () async { final gateway = OpdsHttpClient(); + for (final url in ['file:///etc/passwd', 'https://reader:secret@books.test/opds']) { await expectLater(gateway.get(catalog, Uri.parse(url)), throwsA(isA())); } @@ -66,6 +72,7 @@ void main() { clientFactory: () => MockClient((_) async => http.Response('12345', 200, headers: {'x-opds-url': catalog.uri.toString()})), ); + await expectLater(gateway.get(catalog, catalog.uri, maxBytes: 4), throwsA(isA())); final progress = []; await gateway.get(catalog, catalog.uri, onProgress: (received, total) => progress.add(received)); @@ -74,6 +81,7 @@ void main() { test('cancelled operations cannot start requests', () async { final cancellation = OpdsCancellation()..cancel(); + await expectLater( OpdsHttpClient().get(catalog, catalog.uri, cancellation: cancellation), throwsA(isA()), diff --git a/app/test/opds/layout_test.dart b/app/test/opds/layout_test.dart index 7f927d42..8320fe41 100644 --- a/app/test/opds/layout_test.dart +++ b/app/test/opds/layout_test.dart @@ -47,7 +47,10 @@ const _fontDirectory = String.fromEnvironment('FLUTTER_FONT_DIR'); final _boundary = GlobalKey(); ThemeData _captureTheme(ThemeData theme) { - if (_fontDirectory.isEmpty) return theme; + if (_fontDirectory.isEmpty) { + return theme; + } + // Explicit component styles omit a font family in the app. Flutter's test // fallback is Ahem rather than the platform font, so resolve it for captures. ButtonStyle? font(ButtonStyle? style) => style?.copyWith( @@ -55,6 +58,7 @@ ThemeData _captureTheme(ThemeData theme) { (states) => style.textStyle?.resolve(states)?.copyWith(fontFamily: 'Roboto'), ), ); + return theme.copyWith( appBarTheme: theme.appBarTheme.copyWith( titleTextStyle: theme.appBarTheme.titleTextStyle?.copyWith(fontFamily: 'Roboto'), @@ -71,16 +75,22 @@ Future _settle(WidgetTester tester) async { await tester.pumpAndSettle(); await tester.runAsync(() => Future.delayed(Duration.zero)); } + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); } Future _snapshot(WidgetTester tester, String name) async { await _settle(tester); - if (!_capture) return; + + if (!_capture) { + return; + } + // Image codecs run outside the widget-test fake clock. await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 120))); await tester.pumpAndSettle(); + await tester.runAsync(() async { final boundary = _boundary.currentContext!.findRenderObject()! as RenderRepaintBoundary; final image = await boundary.toImage(); @@ -99,6 +109,7 @@ void main() { family, )..addFont(Future.value(ByteData.sublistView(File('$_fontDirectory/$file').readAsBytesSync())))).load(); } + await (FontLoader('MadimiOne')..addFont(rootBundle.load('fonts/MadimiOne-Regular.ttf'))).load(); } }); @@ -122,36 +133,46 @@ void main() { ? 951 : 900, ); + tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); SharedPreferences.setMockInitialValues({}); final store = OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()); + await store.save( 'local--guest', OpdsCatalog(id: 'gutenberg', name: 'Project Gutenberg', uri: Uri.parse('https://www.gutenberg.org/feed')), ); + await store.save( 'local--guest', OpdsCatalog(id: 'standard', name: 'Standard Ebooks', uri: Uri.parse('https://standard.test/opds')), ); + final dataStore = DataStore(); final opdsLibrary = OpdsLibrary(await SharedPreferences.getInstance(), dataStore: dataStore); final cache = OpdsResourceCache(await SharedPreferences.getInstance()); final catalogs = OpdsCatalogs(store, library: opdsLibrary, cache: cache)..setScope('local--guest'); final cover = File('assets/images/book_placeholder.jpg').readAsBytesSync(); var offline = false; + final gateway = OpdsHttpClient( cache: cache, clientFactory: () => MockRelayClient((request) async { - if (offline) throw http.ClientException('Offline'); + if (offline) { + throw http.ClientException('Offline'); + } + if (request.url.path.endsWith('.jpg')) { return http.Response.bytes(cover, 200, headers: {'content-type': 'image/jpeg'}); } + if (request.url.path.endsWith('.epub')) { return http.Response('Review book bytes', 200, headers: {'content-type': 'application/epub+zip'}); } + return http.Response( jsonEncode({ 'metadata': { @@ -231,6 +252,7 @@ void main() { ); }), ); + final downloads = OpdsDownloads( library: opdsLibrary, httpClient: gateway, @@ -252,19 +274,23 @@ void main() { author: 'Jane Austen', addedAt: DateTime(2026), ); + dataStore.replaceBooksFromSync([book]); return book; }, ), ); + final sidebar = SidebarProvider(); final library = LibraryProvider(); + final reference = Book( id: 'reference', title: 'Pride and Prejudice', author: 'Jane Austen', addedAt: DateTime(2026), ); + final router = GoRouter( initialLocation: '/library/catalogs', routes: [ @@ -306,6 +332,7 @@ void main() { ), ], ); + await tester.pumpWidget( MultiProvider( providers: [ @@ -330,6 +357,7 @@ void main() { ), ), ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); router.dispose(); @@ -341,14 +369,17 @@ void main() { sidebar.dispose(); library.dispose(); }); + final label = '$name-${width.toInt()}-${scale.toInt()}x'; await _snapshot(tester, '$label-sources'); + if (width < 840) { expect(find.byKey(const Key('catalog-mobile-header')), findsNothing); expect(find.byType(LibraryMobileToolbar), findsOneWidget); expect(find.byTooltip('Library sections').hitTestable(), findsOneWidget); expect(find.byKey(const Key('catalog-header-divider')), findsNothing); expect(find.byTooltip('Catalog options'), findsNothing); + for (final (direction, action) in [(1.0, 'edit'), (-1.0, 'delete')]) { final sourceRect = tester.getRect(find.byType(CatalogSourceTile).first); final gesture = await tester.startGesture(sourceRect.center); @@ -360,19 +391,24 @@ void main() { await gesture.up(); await _settle(tester); expect(find.byType(BottomSheet), findsOneWidget); + if (action == 'edit') { expect(find.byKey(const Key('opds-name')), findsOneWidget); } else { expect(find.text('Remove catalog'), findsOneWidget); } + await tester.tap(find.text('Cancel')); await _settle(tester); } + expect(find.byType(FloatingActionButton), findsOneWidget); + expect( tester.getBottomRight(find.byType(FloatingActionButton)).dy, lessThan(tester.getTopLeft(find.byType(NavigationBar)).dy), ); + expect( tester.getCenter(find.byTooltip('Downloads')).dy, tester.getCenter(find.byTooltip('Library sections')).dy, @@ -381,12 +417,15 @@ void main() { expect(find.byType(FloatingActionButton), findsNothing); expect(find.widgetWithText(FilledButton, 'Add catalog'), findsOneWidget); } + await tester.tap(find.text('Project Gutenberg')); await _snapshot(tester, '$label-feed'); + if (width < 840) { expect(find.byKey(const Key('catalog-mobile-header')), findsOneWidget); expect(find.byTooltip('Back').hitTestable(), findsOneWidget); } + offline = true; await tester.tap(find.byTooltip('Refresh catalog')); await _snapshot(tester, '$label-cached-feed'); @@ -395,39 +434,49 @@ void main() { await tester.tap(find.text('Retry')); await _settle(tester); expect(find.byType(FloatingActionButton), findsNothing); + expect( find.text('Popular books'), findsOneWidget, - reason: tester.widgetList(find.byType(Text)).map((t) => t.data).join(' | '), + reason: tester.widgetList(find.byType(Text)).map((item) => item.data).join(' | '), ); + await tester.scrollUntilVisible( find.text('Next'), 200, scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, ); + await _snapshot(tester, '$label-pagination'); + tester .state( find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, ) .position .jumpTo(0); + await tester.pumpAndSettle(); + router.go( Uri(path: '/library/catalogs/gutenberg', queryParameters: {'feed': 'https://books.test/editions'}).toString(), ); + await _snapshot(tester, '$label-editions'); expect(find.text('Pride and Prejudice'), findsNWidgets(2)); router.go('/library/catalogs/gutenberg'); await _settle(tester); + await tester.scrollUntilVisible( find.text('Pride and Prejudice'), 200, scrollable: find.descendant(of: find.byType(CustomScrollView), matching: find.byType(Scrollable)).first, ); + await tester.pumpAndSettle(); await tester.tap(find.text('Pride and Prejudice')); await _snapshot(tester, '$label-details'); + if (width < 840) { final header = find.byKey(const Key('catalog-book-mobile-header')); expect(tester.getSize(header).height, kToolbarHeight); @@ -440,13 +489,16 @@ void main() { expect(tester.getTopLeft(rail).dy, tester.getBottomLeft(header).dy); await _snapshot(tester, '$label-details-scrolled'); } + expect(find.text('Details'), findsOneWidget); await tester.ensureVisible(find.text('Description')); await _snapshot(tester, '$label-details-content'); + if (find.byType(NestedScrollView).evaluate().isNotEmpty) { await tester.dragFrom(Offset(width / 2, 200), const Offset(0, 2500)); await tester.pumpAndSettle(); } + await tester.ensureVisible(find.text('Add to library')); await tester.pumpAndSettle(); await tester.tap(find.text('Add to library')); @@ -454,6 +506,7 @@ void main() { await tester.ensureVisible(find.textContaining('Other catalog options')); await tester.tap(find.textContaining('Other catalog options')); await _snapshot(tester, '$label-formats-expanded'); + if (_capture && width >= 840) { final mouse = await tester.createGesture(kind: ui.PointerDeviceKind.mouse); await mouse.addPointer(location: Offset.zero); @@ -461,6 +514,7 @@ void main() { await _snapshot(tester, '$label-formats-hover'); await mouse.removePointer(); } + await tester.tap(find.text('Close')); await tester.pumpAndSettle(); final selected = tester.widget(find.byType(OpdsPublicationDetails)).publication; @@ -482,17 +536,20 @@ void main() { await tester.tap(find.text('Close')); router.go('/library/catalogs'); await _settle(tester); + await tester.tap( find.byType(FloatingActionButton).evaluate().isNotEmpty ? find.byTooltip('Add catalog') : find.text('Add catalog'), ); + await _snapshot(tester, '$label-editor'); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await _snapshot(tester, '$label-editor-keyboard'); tester.view.resetViewInsets(); await tester.tap(find.text('Cancel')); await _settle(tester); + if (_capture && width >= 1280 && scale == 1) { router.go('/reference'); await _snapshot(tester, '$label-library-reference'); diff --git a/app/test/opds/library_test.dart b/app/test/opds/library_test.dart index 1397f5de..255489ca 100644 --- a/app/test/opds/library_test.dart +++ b/app/test/opds/library_test.dart @@ -31,6 +31,7 @@ void main() { late SharedPreferences prefs; late DataStore store; late OpdsLibrary library; + setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); @@ -38,6 +39,7 @@ void main() { await store.waitUntilLoaded(); library = OpdsLibrary(prefs, dataStore: store)..setScope('guest'); }); + tearDown(() { library.dispose(); store.dispose(); @@ -54,10 +56,12 @@ void main() { expect(library.bookId(_catalog, _publication, link: _pdf), isNull); expect(library.bookId(_catalog, OpdsPublication(id: 'other-edition', title: 'Book')), isNull); expect(library.bookId(OpdsCatalog(id: 'other', name: 'Books', uri: _catalog.uri), _publication), isNull); + expect( library.bookId(OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://other.test')), _publication), isNull, ); + store.replaceBooksFromSync([]); expect(library.bookId(_catalog, _publication), isNull); }); @@ -110,16 +114,19 @@ void main() { test('renaming preserves provenance; credentials and removal invalidate it', () async { final catalogs = OpdsCatalogs(OpdsCatalogStore(prefs, secrets: MemorySecrets()), library: library) ..setScope('guest'); + addTearDown(catalogs.dispose); await catalogs.save(_catalog); store.replaceBooksFromSync([_book]); await library.record(library.capture(_catalog, _publication, _epub)!, _book.id); await catalogs.save(OpdsCatalog(id: 'one', name: 'Renamed', uri: _catalog.uri)); expect(library.bookId(_catalog, _publication), _book.id); + await catalogs.save( _catalog, credentials: const OpdsCredentials(username: 'new', password: 'secret'), ); + expect(library.bookId(_catalog, _publication), isNull); await library.record(library.capture(_catalog, _publication, _epub)!, _book.id); await catalogs.remove(_catalog.id); @@ -130,6 +137,7 @@ void main() { test('successful import is remembered; duplicate acquisition skips network; deletion permits reimport', () async { var requests = 0; var commits = 0; + final downloads = OpdsDownloads( library: library, httpClient: OpdsHttpClient( @@ -156,6 +164,7 @@ void main() { }, ), ); + addTearDown(downloads.dispose); await downloads.start(_catalog, _publication, _epub); expect(library.bookId(_catalog, _publication), _book.id); @@ -188,6 +197,7 @@ void main() { commit: (_, _) async => throw StateError('Commit failed'), ), ); + addTearDown(downloads.dispose); await downloads.start(_catalog, _publication, _epub); store.replaceBooksFromSync([_book]); @@ -198,6 +208,7 @@ void main() { test('account switch during commit cannot record in the newly selected library', () async { final commitStarted = Completer(); final committed = Completer(); + final downloads = OpdsDownloads( library: library, httpClient: OpdsHttpClient(clientFactory: () => MockRelayClient((_) async => http.Response('book', 200))), @@ -218,6 +229,7 @@ void main() { }, ), ); + addTearDown(downloads.dispose); final operation = downloads.start(_catalog, _publication, _epub); await commitStarted.future; diff --git a/app/test/opds/library_widgets_test.dart b/app/test/opds/library_widgets_test.dart index 561e58c6..a39e369b 100644 --- a/app/test/opds/library_widgets_test.dart +++ b/app/test/opds/library_widgets_test.dart @@ -14,12 +14,14 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { final catalog = OpdsCatalog(id: 'c', name: 'Catalog', uri: Uri.parse('https://books.test/feed')); + final epub = OpdsLink( uri: Uri.parse('https://books.test/book.epub'), type: 'application/epub+zip', title: 'EPUB (no images, older E-readers)', rels: ['download'], ); + final pdf = OpdsLink(uri: Uri.parse('https://books.test/book.pdf'), type: 'application/pdf', rels: ['download']); final publication = OpdsPublication(id: 'one', title: 'A book', links: [epub, pdf]); final book = Book(id: 'local', title: 'A book', author: 'Author', addedAt: DateTime(2026)); @@ -39,6 +41,7 @@ void main() { library = OpdsLibrary(prefs, dataStore: store)..setScope('guest'); downloads = OpdsDownloads(library: library, captureImport: () => throw StateError('Must not import')); }); + tearDown(() { downloads.dispose(); library.dispose(); @@ -53,6 +56,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + final router = GoRouter( initialLocation: '/catalog', routes: [ @@ -75,7 +79,9 @@ void main() { ), ], ); + addTearDown(router.dispose); + await tester.pumpWidget( MaterialApp.router( theme: AppTheme.eink, @@ -86,6 +92,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.text('In library'), findsNothing); expect(find.text('Open book'), findsOneWidget); @@ -97,12 +104,14 @@ void main() { final formatRect = tester.getRect(find.text('EPUB')); final detailRect = tester.getRect(find.text(epub.title!)); final openRect = tester.getRect(find.ancestor(of: sheetOpen, matching: find.byType(FilledButton))); + if (scale == 1) { expect(openRect.center.dy, closeTo((formatRect.top + detailRect.bottom) / 2, 0.1)); expect(openRect.left, greaterThan(detailRect.right)); } else { expect(openRect.top, greaterThan(detailRect.bottom)); } + expect(find.text('Download PDF'), findsOneWidget); expect(find.text('Download EPUB'), findsNothing); await tester.tap(sheetOpen); @@ -135,6 +144,7 @@ void main() { OpdsPublication(id: 'another-edition', title: 'A book', links: [epub]), ], ); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -157,6 +167,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.textContaining('In library'), findsOneWidget); store.replaceBooksFromSync([]); diff --git a/app/test/opds/network_smoke_test.dart b/app/test/opds/network_smoke_test.dart index 440652b8..e032fa4b 100644 --- a/app/test/opds/network_smoke_test.dart +++ b/app/test/opds/network_smoke_test.dart @@ -34,12 +34,14 @@ void main() { for (final protected in [false, true]) { final access = protected ? 'protected' : 'public'; final extension = version == 'v1' ? 'xml' : 'json'; + test('$access $version navigation, details, search, pagination and EPUB download', () async { final catalog = OpdsCatalog( id: '$access-$version', name: 'Fixture', uri: base.resolve('/$access/$version/catalog.$extension'), ); + final credentials = protected ? _credentials : null; final root = await fetch(catalog, catalog.uri, credentials: credentials); expect(root.title, 'Fixture catalog'); @@ -49,35 +51,38 @@ void main() { final publication = books.publications.single; expect(publication.title, 'Network fixture book'); expect(publication.authors, ['Fixture Author']); - final detail = await fetch(catalog, publication.detailLink!.uri, credentials: credentials); expect(detail.publications.single.description, 'Complete publication details.'); final next = await fetch(catalog, books.nextLink!.uri, credentials: credentials); expect(next.title, 'Second page'); expect(next.previousLink!.uri, booksUri); - var search = root.searchLink!; + if (version == 'v1') { final description = await gateway.get(catalog, search.uri, credentials: credentials); search = OpdsSearch.fromOpenSearch(description.text, description.uri); } + const query = 'tea & coffee ž'; + final results = await fetch( catalog, Uri.parse(OpdsSearch.expand(search.template, query)), credentials: credentials, ); - expect(results.title, 'Search: $query'); + expect(results.title, 'Search: $query'); final download = publication.links.singleWhere((link) => link.supportedExtension == 'epub'); expect(download.uri.path, '/$access/$version/book.epub'); final received = []; + final response = await gateway.get( catalog, download.uri, credentials: credentials, onProgress: (bytes, total) => received.add(bytes), ); + expect(received.last, response.bytes.length); final archive = ZipDecoder().decodeBytes(response.bytes, verify: true); expect(utf8.decode(archive.findFile('mimetype')!.content), 'application/epub+zip'); @@ -89,6 +94,7 @@ void main() { test('protected catalog rejects missing and incorrect credentials', () async { final catalog = OpdsCatalog(id: 'auth', name: 'Protected', uri: base.resolve('/protected/v1/catalog.xml')); + for (final credentials in [null, const OpdsCredentials(username: 'reader', password: 'wrong')]) { await expectLater( gateway.get(catalog, catalog.uri, credentials: credentials), @@ -100,11 +106,13 @@ void main() { for (final version in ['v1', 'v2']) { test('$version redirects retain the final URL for relative publication links', () async { final extension = version == 'v1' ? 'xml' : 'json'; + final catalog = OpdsCatalog( id: 'redirect-$version', name: 'Redirect', uri: base.resolve('/protected/redirect-$version'), ); + final response = await gateway.get(catalog, catalog.uri, credentials: _credentials); expect(response.uri.path, '/protected/$version/books/feed.$extension'); final feed = OpdsParser.parse(response.text, response.uri); @@ -120,6 +128,7 @@ void main() { name: 'Redirect auth', uri: base.resolve('/protected/cross-origin'), ); + final response = await gateway.get(catalog, catalog.uri, credentials: _credentials); expect(response.uri.host, 'localhost'); expect(response.uri.origin, isNot(catalog.uri.origin)); diff --git a/app/test/opds/parser_presentation_test.dart b/app/test/opds/parser_presentation_test.dart index 0475cf31..dbf62cc1 100644 --- a/app/test/opds/parser_presentation_test.dart +++ b/app/test/opds/parser_presentation_test.dart @@ -39,6 +39,7 @@ void main() { Language: EnglishSubject: Fiction ''', uri); + expect( feed.publications.single.description, 'This edition has images.\n\nTitle: Book one\n\nSummary: A story with some emphasis.\n\n' @@ -52,6 +53,7 @@ void main() { "publications":[{"metadata":{"title":"Book one", "description":"

First paragraph.

Second & last paragraph.

"}}] }''', uri); + expect(feed.publications.single.description, 'First paragraph.\n\nSecond & last paragraph.'); }); @@ -64,6 +66,7 @@ void main() { ''', uri); + final book = feed.publications.single; expect(feed.navigation, isEmpty); expect(book.title, 'Book one'); @@ -84,6 +87,7 @@ void main() { ''', uri); + expect(feed.publications, isEmpty); expect(feed.navigation.map((link) => link.title), ['Fiction', 'Books by this author']); expect(feed.navigation.first.description, 'Stories to explore.'); @@ -102,6 +106,7 @@ void main() { ''', Uri.parse('https://www.gutenberg.org/ebooks/search.opds/')); + expect(feed.publications, isEmpty); final link = feed.navigation.single; expect(link.title, 'Pride and Prejudice'); @@ -117,6 +122,7 @@ void main() { "description":"

Stories to explore.

", "images":[{"href":"covers/fiction.jpg","type":"image/jpeg"}]}] }''', uri); + expect(feed.navigation.single.description, 'Stories to explore.'); expect(feed.navigation.single.imageUri, uri.resolve('covers/fiction.jpg')); }); diff --git a/app/test/opds/parser_test.dart b/app/test/opds/parser_test.dart index 54f8f5fa..2cede53a 100644 --- a/app/test/opds/parser_test.dart +++ b/app/test/opds/parser_test.dart @@ -45,6 +45,7 @@ void main() { uri, contentType: 'application/octet-stream', ); + expect(feed.title, 'Catalog'); expect(feed.navigation.single.title, 'Fiction'); expect(feed.navigation.single.uri.toString(), 'https://books.example/root/genres/fiction'); @@ -69,6 +70,7 @@ void main() { ''', uri).publications.single; + expect(publication.id, 'urn:book:1'); expect(publication.authors, ['Alice', 'Bob']); expect(publication.description, 'A story.'); @@ -96,6 +98,7 @@ void main() { ''', uri); + expect(feed.facets.single.title, 'Language'); expect(feed.facets.single.links.single.title, 'English'); expect(feed.groups.single.title, 'Featured'); @@ -165,6 +168,7 @@ void main() { uri, contentType: 'text/plain', ); + expect(feed.title, 'Catalog'); expect(feed.nextLink!.uri.query, 'page=2'); expect(feed.previousLink!.uri.query, 'page=0'); @@ -189,6 +193,7 @@ void main() { uri, contentType: 'application/opds-publication+json', ); + expect(feed.publications.single.title, 'A book'); expect(feed.publications.single.links.single.indirect, isTrue); expect(feed.publications.single.links.single.supportedExtension, isNull); @@ -215,6 +220,7 @@ void main() { test('models expose only supported direct downloadable acquisitions', () { OpdsLink link(String href, String? type, {String rel = acquisition, bool indirect = false}) => OpdsLink(uri: uri.resolve(href), type: type, rels: [rel], indirect: indirect); + expect(link('download', 'application/epub+zip').supportedExtension, 'epub'); expect(link('FILE.CBZ?token=x', null).supportedExtension, 'cbz'); expect(link('book.mobi', 'application/octet-stream').supportedExtension, 'mobi'); @@ -245,15 +251,18 @@ void main() { expect(link.isAcquisition, isTrue, reason: rel); expect(link.supportedExtension, 'epub', reason: rel); } + for (final rel in ['buy', 'borrow', 'subscribe']) { final link = OpdsLink(uri: uri.resolve('book.epub'), rels: [rel]); expect(link.isAcquisition, isTrue, reason: rel); expect(link.supportedExtension, isNull, reason: rel); } + final feed = OpdsParser.parse('''{ "metadata":{"title":"Book"}, "links":[{"rel":"download","href":"book.epub"}] }''', uri); + expect(feed.publications.single.title, 'Book'); }); @@ -268,6 +277,7 @@ void main() { ]) { final feed = OpdsParser.parse(''' Book$content''', uri); + expect(feed.publications.single.description, 'One\n\nTwo & three'); } }); @@ -277,11 +287,14 @@ void main() { OpdsSearch.expand('https://example.test/search{?query,author}', 'A & B/ž'), 'https://example.test/search?query=A%20%26%20B%2F%C5%BE', ); + expect( OpdsSearch.expand('https://example.test/search?format=json{&query,lang}', 'a+b'), 'https://example.test/search?format=json&query=a%2Bb', ); + expect(OpdsSearch.expand('https://example.test/search?q={query}', 'a b'), 'https://example.test/search?q=a%20b'); + expect( OpdsSearch.expand(Uri.parse('https://example.test/search{?query}').toString(), 'book'), 'https://example.test/search?query=book', @@ -296,6 +309,7 @@ void main() { ), 'https://example.test/?q=tea%20%26%20coffee&start=1&page=1&n=&lang=&encoding=UTF-8', ); + expect(() => OpdsSearch.expand('https://example.test/?q={unknown}', 'book'), throwsFormatException); }); @@ -318,6 +332,7 @@ void main() { ''', uri); + expect(link.templated, isTrue); expect(link.hasRel('search'), isTrue); expect(OpdsSearch.expand(link.template, 'two words'), 'https://books.example/v1/search?q=two%20words&page='); diff --git a/app/test/opds/parser_validation_test.dart b/app/test/opds/parser_validation_test.dart index da9dfcab..411e1f82 100644 --- a/app/test/opds/parser_validation_test.dart +++ b/app/test/opds/parser_validation_test.dart @@ -3,6 +3,7 @@ import 'package:papyrus/opds/opds_parser.dart'; void main() { final uri = Uri.parse('https://books.test/opds'); + test('rejects JSON without catalog structure or valid publication members', () { for (final body in [ '{"metadata":{"title":"x"}}', @@ -12,10 +13,12 @@ void main() { expect(() => OpdsParser.parse(body, uri), throwsFormatException); } }); + test('selects supported keyword search after an HTML search link', () { final feed = OpdsParser.parse('''{"metadata":{"title":"Books"},"navigation":[],"links":[ {"rel":"search","href":"/website-search","type":"text/html"}, {"rel":"search","href":"/opds-search{?query}","type":"application/opds+json","templated":true}]}''', uri); + expect(feed.searchLink!.template, contains('opds-search{?query}')); }); } diff --git a/app/test/opds/presentation_test.dart b/app/test/opds/presentation_test.dart index 95f540d9..9c2557b8 100644 --- a/app/test/opds/presentation_test.dart +++ b/app/test/opds/presentation_test.dart @@ -58,6 +58,7 @@ Future _mount( addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final transfers = downloads ?? OpdsDownloads(captureImport: () => throw StateError('Unused import')); + await tester.pumpWidget( ChangeNotifierProvider.value( value: transfers, @@ -74,10 +75,12 @@ Future _mount( ), ), ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); transfers.dispose(); }); + await _settle(tester); } @@ -107,8 +110,11 @@ class _PendingHttp extends OpdsHttpClient { int maxBytes = 8 * 1024 * 1024, }) { cancellation?.addListener(() { - if (!response.isCompleted) response.completeError(const OpdsCancelled()); + if (!response.isCompleted) { + response.completeError(const OpdsCancelled()); + } }); + onProgress?.call(512, 1024); return response.future; } @@ -130,6 +136,7 @@ void main() { ]) { testWidgets('catalog details fit $size at text scale $textScale with bottom navigation', (tester) async { final downloads = OpdsDownloads(captureImport: _unusedSession); + await _mount( tester, Scaffold( @@ -158,6 +165,7 @@ void main() { textScale: textScale, downloads: downloads, ); + expect(tester.takeException(), isNull); // Exercise the exact boundary that previously made the fixed tab row @@ -171,17 +179,18 @@ void main() { expect(tester.takeException(), isNull); tester.view.physicalSize = size; await _settle(tester); - final rail = find.byType(BookDetailsTabRail); final navigationTop = tester.getTopLeft(find.byType(NavigationBar)).dy; await tester.dragFrom(Offset(size.width / 2, navigationTop - 30), const Offset(0, -1000)); await _settle(tester); expect(tester.getTopLeft(rail).dy, appBarBottom); expect(tester.getBottomLeft(rail).dy, lessThan(navigationTop)); + final contentViewport = find.descendant( of: find.byType(TabBarView), matching: find.byType(SingleChildScrollView), ); + expect(tester.getTopLeft(contentViewport).dy, tester.getBottomLeft(rail).dy); expect(tester.getBottomLeft(contentViewport).dy, navigationTop); expect(tester.takeException(), isNull); @@ -191,6 +200,7 @@ void main() { for (final width in [360.0, 424.0]) { testWidgets('mobile $width details rail stays below the header while content scrolls', (tester) async { final downloads = OpdsDownloads(captureImport: _unusedSession); + await _mount( tester, Scaffold( @@ -208,6 +218,7 @@ void main() { width: width, downloads: downloads, ); + final rail = find.byType(BookDetailsTabRail); final headerBottom = tester.getBottomLeft(find.byType(AppBar)).dy; final description = find.byKey(const Key('opds-description')); @@ -257,6 +268,7 @@ void main() { theme: AppTheme.dark, width: 424, ); + final card = find.byKey(const ValueKey('one')); final title = find.descendant(of: card, matching: find.text('Short title')); expect(tester.getSize(title).height, lessThan(26)); @@ -264,19 +276,24 @@ void main() { expect(tester.getTopLeft(author).dy - tester.getBottomLeft(title).dy, lessThanOrEqualTo(8)); expect(tester.widget(find.descendant(of: card, matching: find.text(_epub.title!))).maxLines, 2); final cover = tester.getRect(find.descendant(of: card, matching: find.byType(OpdsCover))); + final other = tester.getRect( find.descendant(of: find.byKey(const ValueKey('two')), matching: find.byType(OpdsCover)), ); + expect(cover.height, closeTo(cover.width * 1.5, 1)); expect(cover.size, other.size); + expect( tester .getRect(find.byTooltip('List view')) .overlaps(tester.getRect(find.text('Pride and Prejudice by Jane Austen'))), isFalse, ); + expect(tester.takeException(), isNull); }); + testWidgets('relay failure offers retry without a manual browser download', (tester) async { final downloads = OpdsDownloads( captureImport: _unusedSession, @@ -286,6 +303,7 @@ void main() { }), ), ); + await downloads.start(_catalog, _publication(), _epub); await _mount(tester, _panel(downloads), theme: AppTheme.light, downloads: downloads); await tester.tap(find.byTooltip('Downloads')); @@ -297,12 +315,14 @@ void main() { }); final themes = [('light', AppTheme.light), ('dark', AppTheme.dark), ('eink', AppTheme.eink)]; + for (final (name, theme) in themes) { for (final width in [360.0, 1280.0]) { testWidgets('$name $width feed switches grid/list and keeps navigation and pagination usable', (tester) async { var grid = true; Uri? navigated; Uri? paged; + final feed = OpdsFeed( uri: _catalog.uri, title: 'Popular books', @@ -323,6 +343,7 @@ void main() { OpdsLink(uri: Uri.parse('https://books.test/feed?page=3'), rels: ['next']), ], ); + await _mount( tester, Padding( @@ -344,17 +365,20 @@ void main() { theme: theme, width: width, ); + expect(find.text('Jane Austen · novels and stories'), findsOneWidget); expect(find.byIcon(Icons.folder_outlined), findsNothing); final first = find.byKey(const ValueKey('one')); final second = find.byKey(const ValueKey('two')); expect(tester.getTopLeft(first).dy, tester.getTopLeft(second).dy); expect(tester.getTopLeft(second).dx, greaterThan(tester.getTopLeft(first).dx)); + expect( tester.getBottomLeft(find.descendant(of: first, matching: find.byType(OpdsCover))).dy, tester.getBottomLeft(find.descendant(of: second, matching: find.byType(OpdsCover))).dy, reason: 'Grid covers align when titles occupy different numbers of lines.', ); + await tester.tap(find.text('An author collection')); expect(navigated, Uri.parse('https://books.test/author')); expect(find.byType(SegmentedButton), findsNothing); @@ -389,8 +413,10 @@ void main() { (index) => 'Paragraph ${index + 1}. This edition includes notes about the story, its characters, and the original publication.', ).join('\n\n'); + final selected = []; final downloads = OpdsDownloads(captureImport: _unusedSession); + await _mount( tester, Padding( @@ -408,6 +434,7 @@ void main() { width: width, downloads: downloads, ); + expect(find.byType(Dialog), findsNothing); expect(find.byType(BottomSheet), findsNothing); expect(find.text(_catalog.name), findsNothing); @@ -420,6 +447,7 @@ void main() { expect(find.text('Download options'), findsOneWidget); await tester.tap(find.text('Download EPUB')); expect(selected, [_epub]); + if (OpdsDownloads.supports(_pdf)) { await tester.tap(find.text('Download PDF')); expect(selected, [_epub, _pdf]); @@ -429,6 +457,7 @@ void main() { await _settle(tester); expect(find.textContaining('cannot be imported'), findsOneWidget); } + await tester.tap(find.text('Close')); await _settle(tester); expect(find.byType(BottomSheet), findsNothing); @@ -470,12 +499,14 @@ void main() { final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); final operation = downloads.start(_catalog, _publication(), _epub); final retried = []; + await _mount( tester, _panel(downloads, onRetry: retried.add), theme: AppTheme.dark, downloads: downloads, ); + gateway.response.completeError(const OpdsException('The catalog could not send this book.')); await operation; await _settle(tester); @@ -496,6 +527,7 @@ void main() { for (final width in [360.0, 424.0, 1280.0]) { testWidgets('$width download panel keeps completed actions beside the book and protects commit', (tester) async { final finish = Completer(); + final downloads = OpdsDownloads( httpClient: _gateway, captureImport: () => BookImportSession( @@ -512,6 +544,7 @@ void main() { commit: (_, _) => finish.future, ), ); + final operation = downloads.start(_catalog, _publication(), _epub); await _mount(tester, _panel(downloads), theme: AppTheme.light, width: width, downloads: downloads); expect(downloads.jobs.single.status, OpdsDownloadStatus.committing); @@ -527,14 +560,17 @@ void main() { await _settle(tester); expect(find.text('Added to library'), findsOneWidget); expect(find.text('Open book'), findsOneWidget); + expect( tester.getCenter(find.text('Open book')).dy, lessThan(tester.getBottomLeft(find.text('Added to library')).dy), ); + expect( tester.getCenter(find.text('Open book')).dy, greaterThan(tester.getTopLeft(find.text('Pride and Prejudice')).dy), ); + await tester.tap(find.byTooltip('Dismiss download')); await tester.pumpAndSettle(); expect(downloads.jobs, isEmpty); diff --git a/app/test/opds/publication_content_test.dart b/app/test/opds/publication_content_test.dart index 65a1613d..3e0e7a1e 100644 --- a/app/test/opds/publication_content_test.dart +++ b/app/test/opds/publication_content_test.dart @@ -11,6 +11,7 @@ void main() { name: 'Renamed catalog', uri: Uri.parse('https://www.gutenberg.org/ebooks.opds'), ); + const description = '''This edition has images. Title: A book @@ -48,6 +49,7 @@ Another paragraph of prose.'''; language: 'en', subjects: ['Fiction'], ); + final content = OpdsPublicationContent.from(gutenberg, publication); expect(content.description, startsWith('A story about a reader.\n\nThis edition has images.')); expect(content.description, endsWith('Another paragraph of prose.')); @@ -67,13 +69,17 @@ Another paragraph of prose.'''; test('other catalogs and unrecognized Gutenberg prose remain unchanged', () { for (final host in ['books.test', 'gutenberg.org.example.test']) { final catalog = OpdsCatalog(id: 'other', name: 'Project Gutenberg', uri: Uri.parse('https://$host/feed')); + final result = OpdsPublicationContent.from( catalog, OpdsPublication(id: '1', title: 'A book', description: description), ); + expect(result.description, description); } + const prose = 'Summary: A single ordinary paragraph.\n\nNothing to extract.'; + expect( OpdsPublicationContent.from(gutenberg, OpdsPublication(id: '1', title: 'Book', description: prose)).description, prose, @@ -91,6 +97,7 @@ Another paragraph of prose.'''; 'Title: Another title\n\nPublisher: First publisher\n\nPublisher: Second publisher\n\nSummary: Story.', ), ); + expect(result.information['Publisher'], 'Structured publisher\nFirst publisher\nSecond publisher'); expect(result.information['Catalog title'], 'Another title'); expect(result.description, 'Story.'); @@ -107,6 +114,7 @@ Another paragraph of prose.'''; 'Summary: Story.\n\nReading Level: Reading ease score: 84.0\n\nLoCC: English literature\n\nCategory: Text', ), ); + expect(result.information['Reading level'], 'Reading ease score: 84.0'); expect(result.information['Classification'], 'English literature'); expect(result.information['Category'], 'Text'); @@ -122,12 +130,15 @@ Another paragraph of prose.'''; ''', gutenberg.uri).publications.single; + expect(publication.published, '1900'); expect(publication.rights, 'Public domain'); expect(publication.subjects, ['Fiction', 'History']); + final onlyTimestamp = OpdsParser.parse(''' Book2026-09-23 ''', gutenberg.uri).publications.single; + expect(onlyTimestamp.published, isNull); }); @@ -152,6 +163,7 @@ Another paragraph of prose.'''; }), gutenberg.uri, ).publications.single; + expect(publication.numberOfPages, 240); expect(publication.subjects, ['Fiction', 'History']); expect(OpdsPublicationContent.from(gutenberg, publication).information['Published'], 'June 1, 1900'); diff --git a/app/test/opds/publication_information_test.dart b/app/test/opds/publication_information_test.dart index 39e0cef6..252e649f 100644 --- a/app/test/opds/publication_information_test.dart +++ b/app/test/opds/publication_information_test.dart @@ -6,6 +6,7 @@ import 'package:papyrus/widgets/opds/opds_publication_information.dart'; void main() { final catalog = OpdsCatalog(id: 'g', name: 'Gutenberg', uri: Uri.parse('https://www.gutenberg.org/ebooks.opds')); + final publication = OpdsPublication( id: 'book', title: 'Book', @@ -25,6 +26,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( MaterialApp( theme: theme, @@ -38,6 +40,7 @@ void main() { ), ), ); + expect(find.text('Description'), findsOneWidget); expect(find.text('Information'), findsOneWidget); expect(find.text('Subjects'), findsOneWidget); @@ -48,6 +51,7 @@ void main() { expect(find.byType(ExpansionTile), findsNothing); final description = tester.getTopLeft(find.text('Description')); final information = tester.getTopLeft(find.text('Information')); + if (width >= 840 && scale == 1) { expect(information.dy, description.dy); expect(information.dx, greaterThan(description.dx)); @@ -55,6 +59,7 @@ void main() { expect(information.dy, greaterThan(description.dy)); expect(information.dx, description.dx); } + expect(tester.takeException(), isNull); }); } @@ -71,6 +76,7 @@ void main() { ), ), ); + expect(find.text('No description available.'), findsOneWidget); expect(find.text('Information'), findsNothing); expect(find.text('Subjects'), findsNothing); diff --git a/app/test/opds/relay_fixture_client.dart b/app/test/opds/relay_fixture_client.dart index 7a19c3f6..8ea302b1 100644 --- a/app/test/opds/relay_fixture_client.dart +++ b/app/test/opds/relay_fixture_client.dart @@ -10,6 +10,7 @@ class MockRelayClient extends MockClient { final payload = jsonDecode(request.body) as Map; final upstream = http.Request('GET', Uri.parse(payload['url'] as String)); final response = await handler(upstream); + return http.Response.bytes( response.bodyBytes, response.statusCode, diff --git a/app/test/opds/relay_http_client_test.dart b/app/test/opds/relay_http_client_test.dart index 59e71a99..75800792 100644 --- a/app/test/opds/relay_http_client_test.dart +++ b/app/test/opds/relay_http_client_test.dart @@ -14,6 +14,7 @@ void main() { test('queues cover fanout and cancels waiting requests before they reach the server', () async { final pending = >[]; + final gateway = OpdsHttpClient( clientFactory: () => MockClient((_) { final response = Completer(); @@ -21,6 +22,7 @@ void main() { return response.future; }), ); + final running = List.generate(4, (_) => gateway.get(catalog, catalog.uri)); await Future.delayed(Duration.zero); expect(pending, hasLength(4)); @@ -31,18 +33,22 @@ void main() { expect(pending, hasLength(4)); cancellation.cancel(); await cancelled; + for (final response in pending) { response.complete(http.Response('book', 200, headers: {'x-opds-url': catalog.uri.toString()})); } + await Future.wait(running); expect(pending, hasLength(4)); }); test('retries temporary relay capacity errors without a direct request', () async { var attempts = 0; + final gateway = OpdsHttpClient( clientFactory: () => MockClient((request) async { expect(request.method, 'POST'); + if (++attempts == 1) { return http.Response( jsonEncode({ @@ -55,32 +61,38 @@ void main() { 503, ); } + return http.Response('book', 200, headers: {'x-opds-url': catalog.uri.toString()}); }), ); + expect((await gateway.get(catalog, catalog.uri)).text, 'book'); expect(attempts, 2); }); test('fetches through the backend without account authentication or a direct catalog request', () async { final seen = []; + final gateway = OpdsHttpClient( clientFactory: () => MockClient((request) async { seen.add(request); return http.Response('book', 200, headers: {'x-opds-url': 'https://cdn.test/book.epub'}); }), ); + final response = await gateway.get(catalog, catalog.uri, credentials: credentials); expect(seen, hasLength(1)); expect(seen.single.url, PapyrusApiConfig.fromEnvironment().endpoint('/opds/relay')); expect(seen.single.method, 'POST'); expect(seen.single.headers.containsKey('authorization'), isFalse); + expect(jsonDecode(seen.single.body), { 'url': catalog.uri.toString(), 'catalog_url': catalog.uri.toString(), 'max_bytes': 8 * 1024 * 1024, 'credentials': {'username': 'reader', 'password': 'secret'}, }); + expect(response.uri, Uri.parse('https://cdn.test/book.epub')); expect(utf8.decode(response.bytes), 'book'); }); @@ -96,6 +108,7 @@ void main() { ), ), ); + await expectLater( gateway.get(catalog, catalog.uri, credentials: credentials), throwsA(isA().having((e) => e.message, 'message', 'Check the catalog credentials.')), @@ -104,16 +117,19 @@ void main() { test('network failure reports the server and never falls back to a direct request', () async { var attempts = 0; + final gateway = OpdsHttpClient( clientFactory: () => MockClient((_) async { attempts++; throw http.ClientException('Failed to fetch'); }), ); + await expectLater( gateway.get(catalog, catalog.uri), throwsA(isA().having((e) => e.message, 'message', contains('server'))), ); + expect(attempts, 1); }); diff --git a/app/test/opds/resource_cache_test.dart b/app/test/opds/resource_cache_test.dart index ca18df1a..c921c2cd 100644 --- a/app/test/opds/resource_cache_test.dart +++ b/app/test/opds/resource_cache_test.dart @@ -13,9 +13,13 @@ import 'package:shared_preferences_platform_interface/shared_preferences_platfor class _FailingPreferences extends InMemorySharedPreferencesStore { _FailingPreferences({this.throwsOnWrite = true}) : super.empty(); final bool throwsOnWrite; + @override Future setValue(String valueType, String key, Object value) async { - if (throwsOnWrite) throw StateError('Quota exceeded'); + if (throwsOnWrite) { + throw StateError('Quota exceeded'); + } + return false; } } @@ -23,6 +27,7 @@ class _FailingPreferences extends InMemorySharedPreferencesStore { class _DelayedPreferences extends InMemorySharedPreferencesStore { _DelayedPreferences() : super.empty(); final firstWrite = Completer(); + @override Future setValue(String valueType, String key, Object value) async { await firstWrite.future; @@ -33,11 +38,13 @@ class _DelayedPreferences extends InMemorySharedPreferencesStore { void main() { final catalog = OpdsCatalog(id: 'one', name: 'Books', uri: Uri.parse('https://books.test/root')); final resource = Uri.parse('https://books.test/feed'); + OpdsResponse response([String body = 'feed']) => OpdsResponse( uri: Uri.parse('https://cdn.test/redirected/feed'), bytes: Uint8List.fromList(utf8.encode(body)), headers: {'content-type': 'application/opds+json', 'authorization': 'secret', 'set-cookie': 'secret'}, ); + setUp(() => SharedPreferences.setMockInitialValues({})); test('cold cache persists bytes, redirect URI and only content type', () async { @@ -149,9 +156,11 @@ void main() { test('bounds serialized bytes and rejects oversized resources', () async { final prefs = await SharedPreferences.getInstance(); final cache = OpdsResourceCache(prefs, maxBytes: 900, maxEntryBytes: 500)..setScope('alice'); + for (var i = 0; i < 10; i++) { await cache.write(cache.capture(catalog, resource.resolve('$i'))!, response('x' * 100)); } + expect(utf8.encode(prefs.getKeys().map(prefs.get).join()).length, lessThanOrEqualTo(900)); final big = cache.capture(catalog, resource)!; await cache.write(big, response('x' * 1000)); diff --git a/app/test/opds/sheets_test.dart b/app/test/opds/sheets_test.dart index 560bbb26..e17a6c90 100644 --- a/app/test/opds/sheets_test.dart +++ b/app/test/opds/sheets_test.dart @@ -60,9 +60,13 @@ class _PendingHttp extends OpdsHttpClient { int maxBytes = 8 * 1024 * 1024, }) { progress = onProgress; + cancellation?.addListener(() { - if (!response.isCompleted) response.completeError(const OpdsCancelled()); + if (!response.isCompleted) { + response.completeError(const OpdsCancelled()); + } }); + onProgress?.call(20, 100); return response.future; } @@ -80,6 +84,7 @@ Future _mountDownloads(WidgetTester tester, OpdsDownloads downloads, Value tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -90,10 +95,12 @@ Future _mountDownloads(WidgetTester tester, OpdsDownloads downloads, Value ), ), ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); downloads.dispose(); }); + await tester.pumpAndSettle(); } @@ -106,6 +113,7 @@ void main() { final store = _PendingStore(await SharedPreferences.getInstance()); final catalogs = OpdsCatalogs(store)..setScope('local--guest'); var sidebarTaps = 0; + final router = GoRouter( routes: [ ShellRoute( @@ -137,17 +145,18 @@ void main() { ), ], ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); router.dispose(); catalogs.dispose(); }); + await tester.pumpWidget(MaterialApp.router(theme: AppTheme.light, routerConfig: router)); await tester.pumpAndSettle(); final sidebarPosition = tester.getCenter(find.text('Sidebar action')); await tester.tap(find.text('Open editor')); await tester.pumpAndSettle(); - expect(AppMotion.disabled(tester.element(find.byType(CatalogEditor))), isTrue); final barrier = tester.getRect(find.byWidgetPredicate((widget) => widget is ModalBarrier && widget.color != null)); expect(barrier.left, 0); @@ -160,7 +169,6 @@ void main() { await tester.pumpAndSettle(); expect(sidebarTaps, 0); expect(find.byType(BottomSheet), findsOneWidget); - store.finish.completeError(const OpdsException('Save failed.')); await tester.pumpAndSettle(); await tester.tapAt(sidebarPosition); @@ -179,8 +187,10 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) ..setScope('local--guest'); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -198,6 +208,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open editor')); await tester.pumpAndSettle(); final title = tester.getRect(find.text('Add catalog')); @@ -212,10 +223,12 @@ void main() { expect(tester.getSize(cancel).height, tester.getSize(save).height); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pumpAndSettle(); + for (final label in ['Cancel', 'Save']) { expect(find.text(label).hitTestable(), findsOneWidget); expect(tester.getBottomLeft(find.text(label)).dy, lessThan(340)); } + expect(tester.takeException(), isNull); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); @@ -228,6 +241,7 @@ void main() { final downloads = OpdsDownloads(httpClient: gateway, captureImport: _unusedSession); final operation = downloads.start(_catalog, _publication, _link); gateway.progress!(20, null); + await tester.pumpWidget( MaterialApp( theme: AppTheme.light, @@ -239,12 +253,14 @@ void main() { ), ), ); + addTearDown(() async { downloads.cancel(downloads.jobs.single.key); await tester.pump(); await tester.pumpWidget(const SizedBox.shrink()); downloads.dispose(); }); + await tester.tap(find.byTooltip('Downloads')); await tester.pump(); final progress = find.byType(AppLinearProgressIndicator); @@ -261,6 +277,7 @@ void main() { SharedPreferences.setMockInitialValues({}); final store = _PendingStore(await SharedPreferences.getInstance()); final catalogs = OpdsCatalogs(store)..setScope('local--guest'); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -274,6 +291,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open editor')); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('opds-name')), 'My catalog'); @@ -307,8 +325,10 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetViewInsets); SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) ..setScope('local--guest'); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -322,6 +342,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open editor')); await tester.pumpAndSettle(); expect(find.byKey(const Key('opds-sheet-header')), findsOneWidget); @@ -398,6 +419,7 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); final gateway = _PendingHttp(); final finish = Completer(); + final downloads = OpdsDownloads( httpClient: gateway, captureImport: () => BookImportSession( @@ -414,7 +436,9 @@ void main() { isCurrent: () => true, ), ); + final operation = downloads.start(_catalog, _publication, _link); + final router = GoRouter( routes: [ GoRoute( @@ -429,6 +453,7 @@ void main() { ), ], ); + await tester.pumpWidget( MaterialApp.router( theme: AppTheme.eink, @@ -439,6 +464,7 @@ void main() { ), ), ); + await tester.tap(find.byTooltip('Downloads')); await tester.pumpAndSettle(); gateway.response.complete(OpdsResponse(uri: _link.uri, bytes: Uint8List.fromList([1, 2, 3]), headers: const {})); diff --git a/app/test/pages/acquisition_page_test.dart b/app/test/pages/acquisition_page_test.dart index 1a86a9f9..a3b1d3a5 100644 --- a/app/test/pages/acquisition_page_test.dart +++ b/app/test/pages/acquisition_page_test.dart @@ -66,6 +66,7 @@ class _FakeAcquisitionApiClient extends AcquisitionApiClient { AcquisitionEndpointKind.readarr: ['BookSearch'], }, ); + Completer? connectionTestCompleter; int connectionTestCalls = 0; int listEndpointCalls = 0; @@ -109,7 +110,6 @@ class _FakeAcquisitionApiClient extends AcquisitionApiClient { createEndpointCalls += 1; lastCreatedName = name; lastCreatedDownloadRoot = downloadRoot; - return AcquisitionEndpoint(id: 'created', name: name, kind: kind, baseUrl: baseUrl, enabled: true); } @@ -130,7 +130,6 @@ class _FakeAcquisitionApiClient extends AcquisitionApiClient { lastUpdatedApiKey = apiKey; lastUpdatedUsername = username; lastUpdatedPassword = password; - return endpointsResult.singleWhere((endpoint) => endpoint.id == endpointId); } @@ -178,26 +177,20 @@ void main() { final apiClient = _FakeAcquisitionApiClient(); await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-sources-section'); - expect(find.byKey(const Key('acquisition-api-key')), findsOneWidget); expect(find.byKey(const Key('acquisition-username')), findsNothing); expect(find.byKey(const Key('acquisition-password')), findsNothing); - await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); await _tapSectionAdd(tester, 'acquisition-clients-section'); - expect(find.byKey(const Key('acquisition-api-key')), findsNothing); expect(find.byKey(const Key('acquisition-username')), findsOneWidget); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); - await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); await tester.tap(find.text('Deluge').last); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-api-key')), findsNothing); expect(find.byKey(const Key('acquisition-username')), findsNothing); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); @@ -209,29 +202,26 @@ void main() { apiClient.connectionTestCompleter = completer; await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-sources-section'); await tester.enterText(find.byKey(const Key('acquisition-name')), 'Prowlarr'); await tester.enterText(find.byKey(const Key('acquisition-url')), 'http://prowlarr.local:9696'); + await tester.scrollUntilVisible( find.byKey(const Key('acquisition-test-connection')), 300, scrollable: find.byType(Scrollable).last, ); + await tester.tap(find.byKey(const Key('acquisition-test-connection'))); await tester.pump(); - expect(apiClient.connectionTestCalls, 1); expect(tester.widget(find.byKey(const Key('acquisition-test-connection'))).onPressed, isNull); expect(tester.widget(find.byKey(const Key('acquisition-save'))).onPressed, isNull); - await tester.tap(find.byKey(const Key('acquisition-test-connection')), warnIfMissed: false); await tester.pump(); expect(apiClient.connectionTestCalls, 1); - completer.completeError(const AuthApiException(statusCode: 502, message: 'Prowlarr connection test failed')); await tester.pumpAndSettle(); - expect(find.text('Prowlarr connection test failed'), findsOneWidget); }); @@ -239,13 +229,11 @@ void main() { final apiClient = _FakeAcquisitionApiClient(); await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-sources-section'); await tester.enterText(find.byKey(const Key('acquisition-name')), 'Home Prowlarr'); await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://prowlarr.local'); await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(apiClient.createEndpointCalls, 1); expect(apiClient.lastCreatedName, 'Home Prowlarr'); expect(apiClient.listEndpointCalls, 2); @@ -255,14 +243,12 @@ void main() { final apiClient = _FakeAcquisitionApiClient(); await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-clients-section'); await tester.enterText(find.byKey(const Key('acquisition-name')), 'Home qBittorrent'); await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://qbittorrent.local'); await tester.enterText(find.byKey(const Key('acquisition-download-root')), ' /downloads/books '); await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(apiClient.createEndpointCalls, 1); expect(apiClient.lastCreatedDownloadRoot, '/downloads/books'); }); @@ -271,13 +257,11 @@ void main() { final apiClient = _FakeAcquisitionApiClient(); await tester.pumpWidget(await _buildPage(apiClient, includeDownloadsProvider: true)); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-sources-section'); await tester.enterText(find.byKey(const Key('acquisition-name')), 'Home Prowlarr'); await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://prowlarr.local'); await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(apiClient.listEndpointCalls, 3); }); @@ -285,12 +269,10 @@ void main() { final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_indexerOne]; await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('acquisition-endpoint-indexer-1'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(apiClient.updateEndpointCalls, 1); expect(apiClient.lastUpdatedEndpointId, _indexerOne.id); expect(apiClient.lastUpdatedApiKey, isNull); @@ -301,10 +283,8 @@ void main() { testWidgets('acquisition page uses the constrained settings-section layout', (tester) async { final apiClient = _FakeAcquisitionApiClient(); - await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - expect(find.text('Acquisition'), findsOneWidget); expect(find.byType(FloatingActionButton), findsNothing); expect(find.byKey(const Key('acquisition-search-section')), findsNothing); @@ -318,9 +298,9 @@ void main() { expect(find.text('Torrent indexers'), findsNothing); expect(find.byType(SettingsCard), findsNWidgets(3)); expect(find.byType(Card), findsNothing); - final listView = tester.widget(find.byType(ListView)); expect(listView.padding, const EdgeInsets.all(Spacing.md)); + expect( find.ancestor( of: find.byKey(const Key('acquisition-sources-section')), @@ -328,6 +308,7 @@ void main() { ), findsOneWidget, ); + expect( find.ancestor( of: find.byKey(const Key('acquisition-sources-section')), @@ -337,6 +318,7 @@ void main() { ), findsOneWidget, ); + expect( find.ancestor(of: find.byKey(const Key('acquisition-sources-section')), matching: find.byType(Center)), findsOneWidget, @@ -349,15 +331,17 @@ void main() { await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - expect(find.text('Prowlarr • indexer-one.local • Enabled'), findsOneWidget); + final configuredClientRow = tester.widget( find.byKey(const Key('acquisition-endpoint-configured-client')), ); + expect(configuredClientRow.value, 'qBittorrent • qbittorrent.local • Enabled'); expect(configuredClientRow.value, isNot(contains('/private/downloads'))); expect(find.text('Transmission • paused-client.local • Paused'), findsOneWidget); expect(find.text('Readarr • readarr.local • Enabled'), findsOneWidget); + expect( find.descendant( of: find.byKey(const Key('acquisition-endpoint-indexer-1')), @@ -365,6 +349,7 @@ void main() { ), findsOneWidget, ); + expect( find.descendant( of: find.byKey(const Key('acquisition-endpoint-paused-client')), @@ -372,6 +357,7 @@ void main() { ), findsOneWidget, ); + expect( find.descendant( of: find.byKey(const Key('acquisition-endpoint-arr-1')), @@ -386,31 +372,25 @@ void main() { expect(find.byTooltip('Actions for Indexer One'), findsOneWidget); expect(find.byTooltip('Actions for Paused Client'), findsOneWidget); expect(find.byTooltip('Actions for Readarr'), findsOneWidget); - await tester.tap(find.byKey(const Key('acquisition-endpoint-indexer-1'))); await tester.pumpAndSettle(); - expect(find.text('Edit integration'), findsOneWidget); }); testWidgets('section Add offers only matching integration types', (tester) async { final apiClient = _FakeAcquisitionApiClient(); - await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-sources-section'); expect(_editorKind(tester), AcquisitionEndpointKind.prowlarr); expect(_editorKinds(tester), [AcquisitionEndpointKind.prowlarr]); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-clients-section'); expect(_editorKind(tester), AcquisitionEndpointKind.qbittorrent); expect(_editorKinds(tester), [AcquisitionEndpointKind.qbittorrent, AcquisitionEndpointKind.deluge]); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); - await _tapSectionAdd(tester, 'acquisition-apps-section'); expect(_editorKind(tester), AcquisitionEndpointKind.readarr); expect(_editorKinds(tester), [AcquisitionEndpointKind.readarr]); @@ -429,7 +409,6 @@ void main() { await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - expect(_sectionAdd('acquisition-sources-section'), findsNothing); expect(_sectionAdd('acquisition-clients-section'), findsOneWidget); expect(_sectionAdd('acquisition-apps-section'), findsNothing); @@ -437,73 +416,59 @@ void main() { testWidgets('remove action confirms through the shelf-style dialog', (tester) async { final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_indexerOne]; - await tester.pumpWidget(await _buildPage(apiClient, includeDownloadsProvider: true)); await tester.pumpAndSettle(); - await _tapEndpointMenuItem(tester, _indexerOne, 'Remove'); - expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(BottomSheet), findsNothing); expect(find.text('Remove integration'), findsOneWidget); expect(find.text('Remove "Indexer One"? Saved credentials for this integration will be removed.'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Remove')); await tester.pumpAndSettle(); - expect(apiClient.deletedEndpointIds, ['indexer-1']); expect(apiClient.listEndpointCalls, 3); }); testWidgets('disabled Arr integration cannot run', (tester) async { final apiClient = _FakeAcquisitionApiClient()..endpointsResult = [_disabledReadarr]; - await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - expect(_endpointMenuItem(tester, _disabledReadarr, 'run').enabled, isFalse); - _selectEndpointMenu(tester, _disabledReadarr, 'run'); await tester.pump(); - expect(apiClient.arrEndpointIds, isEmpty); }); testWidgets('active Arr run parses manual IDs and disables Run while pending', (tester) async { final completer = Completer(); + final apiClient = _FakeAcquisitionApiClient() ..endpointsResult = [_readarr] ..arrCommandCompleter = completer; await tester.pumpWidget(await _buildPage(apiClient)); await tester.pumpAndSettle(); - await _tapEndpointMenuItem(tester, _readarr, 'Run action'); - expect(find.byKey(const Key('acquisition-command-sheet')), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); - await tester.tap(find.text('Search books')); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-arr-ids-sheet')), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); - await tester.enterText(find.widgetWithText(TextField, 'IDs'), '42, invalid, 84'); await tester.tap(find.widgetWithText(FilledButton, 'Run')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(apiClient.arrEndpointIds, ['arr-1']); expect(apiClient.arrCommands, ['BookSearch']); + expect(apiClient.arrIds, [ [42, 84], ]); - expect(_endpointMenuItem(tester, _readarr, 'run').enabled, isFalse); + expect(_endpointMenuItem(tester, _readarr, 'run').enabled, isFalse); completer.complete(_job(status: 'submitted')); await tester.pumpAndSettle(); - expect(_endpointMenuItem(tester, _readarr, 'run').enabled, isTrue); }); } @@ -519,7 +484,6 @@ AcquisitionEndpointKind? _editorKind(WidgetTester tester) { List _editorKinds(WidgetTester tester) { final field = find.byType(DropdownButtonFormField); final dropdown = find.descendant(of: field, matching: find.byType(DropdownButton)); - return tester.widget>(dropdown).items!.map((item) => item.value!).toList(); } @@ -529,9 +493,7 @@ Finder _sectionAdd(String sectionKey) { Future _tapSectionAdd(WidgetTester tester, String sectionKey) async { final addButton = _sectionAdd(sectionKey); - expect(addButton, findsOneWidget); - await tester.ensureVisible(addButton); await tester.tap(addButton); await tester.pumpAndSettle(); @@ -542,6 +504,7 @@ List _endpointMenuValues(WidgetTester tester, AcquisitionEndpoint endpo of: find.byKey(Key('acquisition-endpoint-${endpoint.id}')), matching: find.byType(PopupMenuButton), ); + final menu = tester.widget>(menuFinder); return menu @@ -556,6 +519,7 @@ PopupMenuItem _endpointMenuItem(WidgetTester tester, AcquisitionEndpoint of: find.byKey(Key('acquisition-endpoint-${endpoint.id}')), matching: find.byType(PopupMenuButton), ); + final menu = tester.widget>(menuFinder); return menu @@ -571,16 +535,12 @@ Future _tapEndpointMenuItem(WidgetTester tester, AcquisitionEndpoint endpo ); expect(menuFinder, findsOneWidget); - await tester.ensureVisible(menuFinder); await tester.pumpAndSettle(); await tester.tap(menuFinder); await tester.pumpAndSettle(); - final itemLabel = find.text(label); - expect(itemLabel, findsOneWidget); - await tester.tap(itemLabel); await tester.pumpAndSettle(); } @@ -590,8 +550,8 @@ void _selectEndpointMenu(WidgetTester tester, AcquisitionEndpoint endpoint, Stri of: find.byKey(Key('acquisition-endpoint-${endpoint.id}')), matching: find.byType(PopupMenuButton), ); - final menu = tester.widget>(menuFinder); + final menu = tester.widget>(menuFinder); menu.onSelected?.call(value); } diff --git a/app/test/pages/annotation_edit_test.dart b/app/test/pages/annotation_edit_test.dart index 451a40f5..ea77421d 100644 --- a/app/test/pages/annotation_edit_test.dart +++ b/app/test/pages/annotation_edit_test.dart @@ -16,6 +16,7 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); final store = DataStore(); store.addBook(buildTestBook(id: 'book', isPhysical: true)); + await store.addAnnotation( Annotation( id: 'annotation', @@ -27,12 +28,14 @@ void main() { createdAt: DateTime.utc(2026), ), ); + await tester.pumpWidget( ChangeNotifierProvider.value( value: store, child: const MaterialApp(home: AnnotationsPage()), ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AnnotationCard)); await tester.pumpAndSettle(); diff --git a/app/test/pages/auth_password_submission_test.dart b/app/test/pages/auth_password_submission_test.dart index 5a350498..07ab9614 100644 --- a/app/test/pages/auth_password_submission_test.dart +++ b/app/test/pages/auth_password_submission_test.dart @@ -92,26 +92,22 @@ void main() { testWidgets('login submits password exactly as typed', (tester) async { setViewport(tester); final repository = await pumpAuthPage(tester, const LoginPage()); - await tester.enterText(find.widgetWithText(TextFormField, 'Email address'), 'reader@example.com'); await tester.enterText(find.widgetWithText(TextFormField, 'Password'), ' NewSecureP@ss123 '); await tester.tap(find.text('Continue')); await tester.pump(); - expect(repository.loginPassword, ' NewSecureP@ss123 '); }); testWidgets('register submits password exactly as typed', (tester) async { setViewport(tester); final repository = await pumpAuthPage(tester, const RegisterPage()); - await tester.enterText(find.widgetWithText(TextFormField, 'Display name'), 'Reader'); await tester.enterText(find.widgetWithText(TextFormField, 'Email address'), 'reader@example.com'); await tester.enterText(find.widgetWithText(TextFormField, 'Password'), ' NewSecureP@ss123 '); await tester.enterText(find.widgetWithText(TextFormField, 'Confirm password'), ' NewSecureP@ss123 '); await tester.tap(find.text('Continue')); await tester.pump(); - expect(repository.registerPassword, ' NewSecureP@ss123 '); }); } diff --git a/app/test/pages/book_details_delete_test.dart b/app/test/pages/book_details_delete_test.dart index 79a32c90..24437fda 100644 --- a/app/test/pages/book_details_delete_test.dart +++ b/app/test/pages/book_details_delete_test.dart @@ -28,6 +28,7 @@ void main() { final mediaUploadQueue = MediaUploadQueue(prefs); await mediaUploadQueue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); final importService = _RecordingBookImportService(); + final book = Book( id: 'book-1', title: 'Delete Me', @@ -53,6 +54,7 @@ void main() { ), ], ); + addTearDown(router.dispose); await tester.pumpWidget( @@ -65,18 +67,15 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); - expect(find.text('Delete book?'), findsOneWidget); - await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); - expect(importService.deletedBookIds, [book.id]); expect(importService.deletedPendingCovers, ['official--user-1:${book.id}']); expect(importService.deletedGuestCovers, isEmpty); @@ -93,7 +92,6 @@ void main() { final mediaUploadQueue = MediaUploadQueue(prefs); final importService = _RecordingBookImportService(); final book = Book(id: 'book-1', title: 'Delete Guest', author: 'Author', addedAt: DateTime.utc(2026)); - await repository.upsert(book); await tester.pump(); @@ -110,6 +108,7 @@ void main() { ), ], ); + addTearDown(router.dispose); await tester.pumpWidget( @@ -122,15 +121,14 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Delete')); await tester.pumpAndSettle(); - expect(importService.deletedGuestCovers, [book.id]); expect(importService.deletedPendingCovers, isEmpty); expect(importService.deletedCachedCovers, isEmpty); @@ -144,7 +142,6 @@ void main() { final mediaUploadQueue = MediaUploadQueue(prefs); final importService = _RecordingBookImportService(); final book = Book(id: 'book-1', title: 'Download Me', author: 'Author', addedAt: DateTime.utc(2026)); - await repository.upsert(book); await tester.pump(); @@ -161,6 +158,7 @@ void main() { ), ], ); + addTearDown(router.dispose); await tester.pumpWidget( @@ -173,14 +171,12 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); - final downloadTop = tester.getTopLeft(find.text('Download')).dy; final deleteTop = tester.getTopLeft(find.text('Delete')).dy; - expect(downloadTop, lessThan(deleteTop)); }); @@ -192,6 +188,7 @@ void main() { final importService = _RecordingBookImportService(); final downloadService = _RecordingBookDownloadService(); final bytes = Uint8List.fromList('cached epub bytes'.codeUnits); + final book = Book( id: 'book-1', title: 'Download Me', @@ -218,6 +215,7 @@ void main() { ), ], ); + addTearDown(router.dispose); await tester.pumpWidget( @@ -232,14 +230,13 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); await tester.tap(find.text('Download')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(downloadService.savedBooks, [book.id]); expect(downloadService.savedBytes, bytes); expect(find.text('Downloaded "Download Me"'), findsOneWidget); diff --git a/app/test/pages/book_details_reader_test.dart b/app/test/pages/book_details_reader_test.dart index 99ef9a97..a5c4f1ae 100644 --- a/app/test/pages/book_details_reader_test.dart +++ b/app/test/pages/book_details_reader_test.dart @@ -17,6 +17,7 @@ void main() { testWidgets('book details scroll when the header leaves less room than the tabs need', (tester) async { const railKey = ValueKey('details tabs'); const bodyKey = ValueKey('details body'); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -34,6 +35,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); await tester.dragFrom(const Offset(200, 450), const Offset(0, -500)); @@ -53,6 +55,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + final book = buildTestBook( title: "Alice's Adventures in Wonderland", author: 'Lewis Carroll', @@ -61,8 +64,10 @@ void main() { pageCount: 98, description: List.filled(40, 'A long book description.').join('\n\n'), ); + final dataStore = DataStore()..loadData(books: [book]); addTearDown(dataStore.dispose); + await tester.pumpWidget( ChangeNotifierProvider.value( value: dataStore, @@ -84,9 +89,9 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); - final rail = find.byType(BookDetailsTabRail); final appBarBottom = tester.getBottomLeft(find.byType(AppBar)).dy; final navigationTop = tester.getTopLeft(find.byType(NavigationBar)).dy; @@ -113,12 +118,14 @@ void main() { final book = buildTestBook(pageCount: 735, description: List.filled(40, 'A long book description.').join('\n\n')); final dataStore = DataStore()..loadData(books: [book]); addTearDown(dataStore.dispose); + await tester.pumpWidget( ChangeNotifierProvider.value( value: dataStore, child: MaterialApp(home: BookDetailsPage(id: book.id)), ), ); + await tester.pumpAndSettle(); final rail = find.byType(BookDetailsTabRail); final headerBottom = tester.getBottomLeft(find.byType(AppBar)).dy; @@ -153,6 +160,7 @@ void main() { final dataStore = DataStore()..loadData(books: [book]); addTearDown(dataStore.dispose); final detailsPath = '/library/details/${book.id}'; + final router = GoRouter( initialLocation: direct ? detailsPath : '/previous', routes: [ @@ -170,18 +178,23 @@ void main() { ), ], ); + addTearDown(router.dispose); + await tester.pumpWidget( ChangeNotifierProvider.value( value: dataStore, child: MaterialApp.router(routerConfig: router), ), ); + await tester.pumpAndSettle(); + if (!direct) { router.push(detailsPath); await tester.pumpAndSettle(); } + expect(find.text('Book details'), findsOneWidget); final cover = tester.getRect(find.byType(CoverImagePreview)); final rail = tester.getRect(find.byType(BookDetailsTabRail)); @@ -203,6 +216,7 @@ void main() { final book = buildTestBook(id: 'mobi-book', fileFormat: BookFormat.mobi); final dataStore = DataStore()..loadData(books: [book]); addTearDown(dataStore.dispose); + final router = GoRouter( initialLocation: '/library/details/${book.id}', routes: [ @@ -212,6 +226,7 @@ void main() { ), ], ); + addTearDown(router.dispose); await tester.pumpWidget( @@ -220,11 +235,10 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.tap(find.text('Read')); await tester.pump(); - expect(find.text('This book format is not supported yet.'), findsOneWidget); }); @@ -232,6 +246,7 @@ void main() { final book = buildTestBook(id: 'epub-book', fileFormat: BookFormat.epub); final dataStore = DataStore()..loadData(books: [book]); addTearDown(dataStore.dispose); + final router = GoRouter( initialLocation: '/library/details/${book.id}', routes: [ @@ -246,6 +261,7 @@ void main() { ), ], ); + addTearDown(router.dispose); await tester.pumpWidget( @@ -254,11 +270,10 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.tap(find.text('Read')); await tester.pumpAndSettle(); - expect(find.text('Reader opened'), findsOneWidget); }); } diff --git a/app/test/pages/book_edit_details_alignment_test.dart b/app/test/pages/book_edit_details_alignment_test.dart index 1b96852c..ab49acfb 100644 --- a/app/test/pages/book_edit_details_alignment_test.dart +++ b/app/test/pages/book_edit_details_alignment_test.dart @@ -31,13 +31,16 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = layout.screen; addTearDown(tester.view.reset); + final store = createTestDataStore( books: [buildTestBook(id: 'book', title: 'A book', author: 'An author')], ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); store.dispose(); }); + Future pump(Widget page) async { await tester.pumpWidget( createTestPage( @@ -57,22 +60,25 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); } await pump(const BookDetailsPage(id: 'book')); + final reference = tester.getRect( find.descendant(of: find.byType(CoverImagePreview), matching: find.byType(CoverImage)), ); + expect(reference.size, const Size(240, 360)); expect(reference.left, layout.screen.width - layout.width + Spacing.lg); final detailsTitle = tester.getRect( find.descendant(of: find.byType(BookHeader), matching: find.text('A book')).last, ); - final detailsGap = detailsTitle.left - reference.right; + final detailsGap = detailsTitle.left - reference.right; await pump(const BookEditPage(id: 'book')); final preview = find.descendant(of: find.byType(CoverImagePicker), matching: find.byType(AspectRatio)); final image = find.descendant(of: find.byType(CoverImagePicker), matching: find.byType(CoverImage)); @@ -80,6 +86,7 @@ void main() { expect(tester.getRect(image), reference, reason: 'Compare the image itself, including any decoration inset'); expect(find.text('Cover'), findsNothing); final metadataHeading = tester.getRect(find.text('Fetch metadata')); + if (layout.width >= ComponentSizes.bookCoverWidthDesktop + Spacing.md + 420 + Spacing.lg * 2) { expect(metadataHeading.left - reference.right, detailsGap); expect(metadataHeading.top, reference.top); @@ -87,11 +94,14 @@ void main() { final search = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; expect(tester.getRect(search).left, detailsTitle.left); } + final formCard = find.ancestor(of: find.text('Basic information'), matching: find.byType(Card)).first; + expect( tester.getRect(formCard).right, layout.screen.width - layout.width + math.min(layout.width, 1120) - Spacing.lg, ); + final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; final saveButton = find.widgetWithText(FilledButton, 'Save'); expect(tester.getRect(saveButton).right, tester.getRect(searchField).right); @@ -108,10 +118,12 @@ void main() { tester.view.physicalSize = screen; addTearDown(tester.view.reset); final store = createTestDataStore(books: [buildTestBook(id: 'book')]); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); store.dispose(); }); + await tester.pumpWidget( createTestPage( dataStore: store, @@ -122,6 +134,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final picker = find.byType(CoverImagePicker); final preview = tester.getRect(find.descendant(of: picker, matching: find.byType(AspectRatio))); diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart index bf7a2cea..76daa56f 100644 --- a/app/test/pages/book_edit_page_layout_test.dart +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -24,6 +24,7 @@ void main() { data: AppTheme.dark, child: const BookEditPage(id: 'book-1'), ); + if (contentWidth != null) { page = Align( alignment: Alignment.topLeft, @@ -38,72 +39,67 @@ void main() { screenSize: size, ), ); + await tester.pumpAndSettle(); } testWidgets('desktop uses a left-aligned page header with a persistent Save action', (tester) async { await pumpPage(tester, size: const Size(1400, 1000)); - expect(find.text('Edit book'), findsOneWidget); expect(find.widgetWithText(OutlinedButton, 'Back'), findsNothing); expect(find.byTooltip('Back to book details'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); expect(find.byIcon(Icons.save_outlined), findsNothing); - final header = find.byKey(const Key('book-edit-desktop-header')); expect(tester.getSize(header).height, ComponentSizes.appBarHeight); expect(find.descendant(of: header, matching: find.text('Frankenstein; or, The Modern Prometheus')), findsNothing); - expect(find.text('Cover'), findsNothing); expect(tester.getTopLeft(find.byType(CoverImagePicker)).dx, Spacing.lg); - final saveButton = find.widgetWithText(FilledButton, 'Save'); expect(find.ancestor(of: saveButton, matching: find.byType(SingleChildScrollView)), findsNothing); }); testWidgets('desktop keeps the cover beside metadata and form sections', (tester) async { await pumpPage(tester, size: const Size(1400, 1000)); - final cover = tester.getTopLeft(find.byType(CoverImagePicker)); final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final formHeading = tester.getTopLeft(find.text('Basic information')); - expect(metadataHeading.dx, greaterThan(cover.dx)); + expect( tester.getTopLeft(find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first).dy, cover.dy, ); + expect(formHeading.dx, closeTo(metadataHeading.dx, 1)); expect(formHeading.dy, greaterThan(metadataHeading.dy)); }); testWidgets('intermediate desktop keeps panes side by side while paired fields stack', (tester) async { await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 800); - final cover = tester.getTopLeft(find.byType(CoverImagePicker)); final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final publisher = tester.getTopLeft(find.text('Publisher')); final language = tester.getTopLeft(find.text('Language')); - expect(metadataHeading.dx, greaterThan(cover.dx)); + expect( tester.getTopLeft(find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first).dy, cover.dy, ); + expect(language.dy, greaterThan(publisher.dy)); expect(tester.takeException(), isNull); }); testWidgets('medium desktop aligns the cover with the page gutter above full-width form sections', (tester) async { await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); - final cover = tester.getTopLeft(find.byType(CoverImagePicker)); final metadataHeading = tester.getTopLeft(find.text('Fetch metadata')); final formHeading = tester.getTopLeft(find.text('Basic information')); final coverPreview = find.descendant(of: find.byType(CoverImagePicker), matching: find.byType(AspectRatio)); final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; final formCard = find.ancestor(of: find.text('Basic information'), matching: find.byType(Card)).first; - expect(metadataHeading.dy, greaterThan(cover.dy)); expect(formHeading.dy, greaterThan(metadataHeading.dy)); expect(tester.getTopLeft(coverPreview).dx, Spacing.lg); @@ -115,12 +111,10 @@ void main() { testWidgets('metadata search precedes a compact visible source selector', (tester) async { await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); - final metadataCard = find.ancestor(of: find.text('Fetch metadata'), matching: find.byType(Card)).first; final searchField = find.ancestor(of: find.text('Search'), matching: find.byType(TextFormField)).first; final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); final sourceLabel = find.text('Source'); - expect(sourceLabel, findsOneWidget); expect(tester.getSize(searchField).width, closeTo(tester.getSize(metadataCard).width - (Spacing.md * 2), 1)); expect(tester.getTopLeft(sourceLabel).dy, greaterThan(tester.getTopLeft(searchField).dy)); @@ -129,21 +123,20 @@ void main() { testWidgets('metadata source selector does not reserve width for a selected icon', (tester) async { await pumpPage(tester, size: const Size(1000, 1200), contentWidth: 640); - final selector = find.byWidgetPredicate((widget) => widget is SegmentedButton); - expect(find.descendant(of: selector, matching: find.byIcon(Icons.check)), findsNothing); }); testWidgets('narrow layouts stack the cover above the form without overflow', (tester) async { await pumpPage(tester, size: const Size(600, 1600)); - expect(find.text('Edit book'), findsOneWidget); expect(find.byType(CoverImagePicker), findsOneWidget); + expect( tester.getTopLeft(find.byType(CoverImagePicker)).dy, lessThan(tester.getTopLeft(find.text('Basic information')).dy), ); + expect(tester.takeException(), isNull); }); }); diff --git a/app/test/pages/forgot_password_page_test.dart b/app/test/pages/forgot_password_page_test.dart index 2b5971ae..5bcfb530 100644 --- a/app/test/pages/forgot_password_page_test.dart +++ b/app/test/pages/forgot_password_page_test.dart @@ -83,11 +83,9 @@ void main() { testWidgets('forgot password request shows check email state without token field', (tester) async { setViewport(tester, const Size(390, 844)); final repository = await pumpPage(tester); - await tester.enterText(find.byType(TextFormField), 'reader@example.com'); await tester.tap(find.text('Continue')); await tester.pump(); - expect(repository.forgotPasswordEmail, 'reader@example.com'); expect(find.text('Check your email'), findsOneWidget); expect(find.text('We sent a password reset link to reader@example.com'), findsOneWidget); @@ -98,12 +96,10 @@ void main() { testWidgets('reset link page submits URL token with new password', (tester) async { setViewport(tester, const Size(390, 844)); final repository = await pumpPage(tester, resetToken: 'reset-token-123', isResetLink: true); - await tester.enterText(find.widgetWithText(TextFormField, 'New password'), ' NewSecureP@ss123 '); await tester.enterText(find.widgetWithText(TextFormField, 'Confirm new password'), ' NewSecureP@ss123 '); await tester.tap(find.text('Continue')); await tester.pump(); - expect(repository.resetToken, 'reset-token-123'); expect(repository.resetPasswordValue, ' NewSecureP@ss123 '); expect(find.text('Password reset'), findsOneWidget); @@ -112,7 +108,6 @@ void main() { testWidgets('reset link page shows invalid state without token', (tester) async { setViewport(tester, const Size(390, 844)); await pumpPage(tester, isResetLink: true); - expect(find.text('Invalid reset link'), findsOneWidget); expect(find.text('Request new link'), findsOneWidget); expect(find.text('Reset token'), findsNothing); diff --git a/app/test/pages/goals_page_test.dart b/app/test/pages/goals_page_test.dart index d984f12e..fdcd95b3 100644 --- a/app/test/pages/goals_page_test.dart +++ b/app/test/pages/goals_page_test.dart @@ -27,7 +27,6 @@ void main() { 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)); @@ -37,29 +36,34 @@ void main() { 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 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); @@ -71,10 +75,12 @@ void main() { 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)); @@ -88,6 +94,7 @@ void main() { 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); @@ -97,12 +104,14 @@ void main() { 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, @@ -116,6 +125,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.byType(FloatingActionButton), findsNothing); expect(find.byTooltip('Log reading').hitTestable(), findsOneWidget); @@ -127,6 +137,7 @@ void main() { 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); @@ -135,12 +146,14 @@ void main() { 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'); @@ -150,6 +163,7 @@ void main() { 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(); @@ -165,8 +179,10 @@ void main() { 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; @@ -174,12 +190,14 @@ void main() { 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!; @@ -188,9 +206,11 @@ void main() { 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; @@ -209,11 +229,13 @@ void main() { expect(tester.takeException(), isNull); }); } + testWidgets('activity keeps recurring goal periods inspectable on a phone', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(400, 900); addTearDown(tester.view.reset); final now = DateTime.now().toUtc(); + final store = DataStore() ..loadData( readingGoals: [ @@ -227,17 +249,21 @@ void main() { ), ], ); + 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, @@ -245,6 +271,7 @@ void main() { .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')); @@ -252,11 +279,13 @@ void main() { 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: [ @@ -275,13 +304,16 @@ void main() { ), ], ); + 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(); @@ -289,10 +321,12 @@ void main() { 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); @@ -300,12 +334,14 @@ void main() { 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: [ @@ -323,6 +359,7 @@ void main() { ), ], ); + await store.commitTracking( activities: [ ReadingActivity( @@ -335,8 +372,10 @@ void main() { ), ], ); + addTearDown(store.dispose); final bucket = PageStorageBucket(); + Future openPage(int revision) async { await tester.pumpWidget( ChangeNotifierProvider.value( @@ -350,6 +389,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Activity')); await tester.pumpAndSettle(); @@ -362,7 +402,6 @@ void main() { 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); @@ -376,7 +415,6 @@ void main() { 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)); @@ -388,6 +426,7 @@ void main() { 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); @@ -396,6 +435,7 @@ void main() { final now = DateTime.utc(2026, 10, 8, 12); 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, @@ -411,6 +451,7 @@ void main() { scope: GoalScope.book, scopeId: book.id, ); + final store = DataStore() ..loadData( books: [book], @@ -423,7 +464,9 @@ void main() { goal('Just begun', GoalType.minutes, 100, GoalPeriod.weekly), ], ); + addTearDown(store.dispose); + final completion = ReadingActivity( id: 'finished', bookId: book.id, @@ -433,6 +476,7 @@ void main() { endTime: now, createdAt: now, ); + await store.commitTracking( activities: [ completion, @@ -446,6 +490,7 @@ void main() { ), ], ); + await tester.pumpWidget( ChangeNotifierProvider.value( value: store, @@ -455,8 +500,10 @@ void main() { ), ), ); + 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', @@ -466,9 +513,11 @@ void main() { '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); @@ -491,11 +540,13 @@ void main() { 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, @@ -504,8 +555,10 @@ void main() { 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( @@ -519,12 +572,14 @@ void main() { ), ], ); + 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); @@ -540,6 +595,7 @@ void main() { 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 { @@ -547,6 +603,7 @@ void main() { tester.view.physicalSize = size; addTearDown(tester.view.reset); final now = DateTime.now().toUtc(); + final store = DataStore() ..loadData( readingGoals: [ @@ -570,7 +627,9 @@ void main() { ), ], ); + addTearDown(store.dispose); + await tester.pumpWidget( ChangeNotifierProvider.value( value: store, @@ -584,7 +643,9 @@ void main() { ), ), ); + await tester.pumpAndSettle(); + if (size.width < 840) { expect(find.byTooltip('New goal').hitTestable(), findsOneWidget); expect(find.byTooltip('Log reading').hitTestable(), findsOneWidget); @@ -592,10 +653,12 @@ void main() { 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, @@ -606,6 +669,7 @@ void main() { ) .first, ); + await tester.ensureVisible(find.widgetWithText(OutlinedButton, 'Filter dates')); await tester.pumpAndSettle(); expect(find.text('Filter dates').hitTestable(), findsOneWidget); diff --git a/app/test/pages/library_content_alignment_test.dart b/app/test/pages/library_content_alignment_test.dart index 233a30d6..dbe0f141 100644 --- a/app/test/pages/library_content_alignment_test.dart +++ b/app/test/pages/library_content_alignment_test.dart @@ -37,6 +37,7 @@ void main() { addTearDown(tester.view.reset); final provider = LibraryProvider(); final downloads = _Downloads(); + final store = createTestDataStore( books: List.generate( 12, @@ -49,12 +50,14 @@ void main() { ), ), ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); provider.dispose(); downloads.dispose(); store.dispose(); }); + await tester.pumpWidget( createTestPage( libraryProvider: provider, @@ -73,6 +76,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final search = tester.getRect(find.byType(TextField)); final mobile = layout.screen.width < 840; @@ -80,8 +84,10 @@ void main() { final gutter = mobile ? 16.0 : 24.0; expect(search.left, closeTo(layout.screen.width - layout.pageWidth + gutter, .001)); expect(toolbar.left, closeTo(search.left, .001)); + if (mobile) { expect(search.right, closeTo(toolbar.right, .001)); + expect( find.descendant(of: find.byType(TextField), matching: find.byTooltip('Library sections')), findsOneWidget, @@ -96,55 +102,67 @@ void main() { final covers = find.descendant(of: find.byType(BookCard), matching: find.byType(CoverImage)); final first = tester.getRect(covers.first); expect(first.left, closeTo(search.left, .001), reason: '${option.columns} columns'); + expect( first.top - tester.getRect(find.byType(LibraryFilterChips)).bottom, closeTo(Spacing.sm, .001), reason: 'Grid covers need the same top inset as list thumbnails at every density', ); + final row = covers .evaluate() .map((element) => tester.getRect(find.byWidget(element.widget))) .where((rect) => (rect.top - first.top).abs() < .001) .toList(); + expect(row.length, option.columns); expect(row.last.right, closeTo(toolbar.right, .001)); final spacing = bookGridLayout(layout.pageWidth - gutter * 2, itemWidth: option.preferredWidth).spacing; + for (var i = 1; i < row.length; i++) { expect(row[i].left - row[i - 1].right, closeTo(spacing, .001)); } + expect(tester.takeException(), isNull); } // Orphan downloads must use the same gutters in grid and list mode. await tester.drag(find.byType(GridView), const Offset(0, -10000)); await tester.pumpAndSettle(); + final placeholderSurface = find.descendant( of: find.byType(AcquisitionPlaceholderCard), matching: find.byType(InkWell), ); - expect(tester.getRect(placeholderSurface.first).left, closeTo(search.left, .001)); + expect(tester.getRect(placeholderSurface.first).left, closeTo(search.left, .001)); provider.setViewMode(LibraryViewMode.list); await tester.pumpAndSettle(); final firstRow = find.byType(BookListItem).first; final firstCover = find.descendant(of: firstRow, matching: find.byType(CoverImage)); final rowContent = find.descendant(of: firstRow, matching: find.byType(Row)).first; expect(tester.getRect(firstCover).left, closeTo(search.left, .001)); + expect( tester.getRect(rowContent).top - tester.getRect(find.byType(LibraryFilterChips)).bottom, closeTo(Spacing.sm, .001), reason: 'List rows retain their own vertical padding', ); + expect(tester.getRect(firstRow).right, closeTo(toolbar.right, .001)); + await tester.drag( find.descendant(of: find.byType(LibraryPage), matching: find.byType(ListView)).last, const Offset(0, -10000), ); + await tester.pumpAndSettle(); + final placeholderCover = find.descendant( of: find.byType(AcquisitionPlaceholderListItem), matching: find.byType(ClipRRect), ); + expect(tester.getRect(placeholderCover).left, closeTo(search.left, .001)); expect(tester.takeException(), isNull); }); diff --git a/app/test/pages/library_headers_test.dart b/app/test/pages/library_headers_test.dart index f88fdfad..78020a89 100644 --- a/app/test/pages/library_headers_test.dart +++ b/app/test/pages/library_headers_test.dart @@ -29,16 +29,20 @@ void main() { addTearDown(tester.view.reset); SharedPreferences.setMockInitialValues({}); final store = createTestDataStore(books: [], shelves: []); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) ..setScope('local--guest'); + final downloads = OpdsDownloads(captureImport: () => throw StateError('Unexpected import')); final httpClient = OpdsHttpClient(); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); store.dispose(); catalogs.dispose(); downloads.dispose(); }); + await tester.pumpWidget( createTestPage( dataStore: store, @@ -59,6 +63,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final shelfAction = find.ancestor(of: find.text('Create shelf'), matching: find.byType(EmptyStateAction)); final catalogAction = find.ancestor(of: find.text('Add catalog'), matching: find.byType(EmptyStateAction)); @@ -67,25 +72,33 @@ void main() { expect(shelfState, findsOneWidget); expect(catalogState, findsOneWidget); final shelfIcon = tester.widget(find.descendant(of: shelfState, matching: find.byIcon(Icons.shelves))); + final catalogIcon = tester.widget( find.descendant(of: catalogState, matching: find.byIcon(Icons.local_library_outlined)), ); + expect(shelfIcon.color, catalogIcon.color); expect(shelfIcon.size, catalogIcon.size); + expect( tester.widget(find.text('No shelves yet')).style, tester.widget(find.text('No catalogs yet')).style, ); + final shelfSubtitle = tester.widget(find.text('Create shelves to organize your books into collections')); + final catalogSubtitle = tester.widget( find.text('Connect an OPDS catalog to explore its collection and add books to your library.'), ); + expect(shelfSubtitle.style, catalogSubtitle.style); expect(tester.getSize(shelfAction), tester.getSize(catalogAction)); expect(tester.getSize(shelfAction).height, greaterThanOrEqualTo(50)); + for (final action in [shelfAction, catalogAction]) { expect(find.descendant(of: action, matching: find.byIcon(Icons.add)), findsOneWidget); } + expect(tester.takeException(), isNull); }); } @@ -130,8 +143,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(find.text(entry.key), findsNothing); expect(find.byType(LibraryPageHeader), findsNothing); @@ -139,19 +152,23 @@ void main() { if (layout.screen.width < 840) { expect(tester.getTopLeft(find.byType(LibraryMobileToolbar)).dy, 16); expect(tester.getTopLeft(find.byType(TextField)).dx, tester.getTopLeft(find.byType(LibraryMobileToolbar)).dx); + expect( find.descendant(of: find.byType(TextField), matching: find.byTooltip('Library sections')), findsOneWidget, ); + expect(tester.getCenter(find.byTooltip('Library sections')).dy, tester.getCenter(find.byType(TextField)).dy); await tester.tap(find.byTooltip('Library sections')); await tester.pumpAndSettle(); expect(find.byType(Drawer), findsOneWidget); } else { expect(tester.getTopLeft(find.byType(LibraryToolbar)).dy, 24); + if (layout.scale == 1 && (entry.key == 'Books' || entry.key == 'Shelves')) { expect(tester.getCenter(find.byType(TextField)).dy, tester.getCenter(find.byType(LibraryAddButton)).dy); } + if (entry.key == 'Shelves') { await tester.tap(find.text('Add shelf')); await tester.pumpAndSettle(); diff --git a/app/test/pages/library_page_test.dart b/app/test/pages/library_page_test.dart index d9bde41d..fa28c686 100644 --- a/app/test/pages/library_page_test.dart +++ b/app/test/pages/library_page_test.dart @@ -70,30 +70,23 @@ void main() { // ======================================================================== // Mobile layout tests // ======================================================================== - group('mobile layout', () { testWidgets('shows loading until the first repository snapshot arrives', (tester) async { final repository = _ControlledBookRepository(); final loadingStore = DataStore(bookRepository: repository); - await tester.pumpWidget(buildPage(store: loadingStore)); - expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.text('No books found'), findsNothing); - repository.controller.add(const []); await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.text('No books found'), findsOneWidget); - await repository.controller.close(); }); testWidgets('renders search bar', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.byType(LibrarySearchBar), findsOneWidget); }); @@ -102,35 +95,28 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Dune Messiah'); - await tester.pumpWidget(buildPage(screenSize: const Size(400, 1000), downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); - expect(gateway.searchQueries, isEmpty); expect(find.text('Search online for “Dune Messiah”'), findsOneWidget); expect(find.textContaining('Downloads'), findsNothing); expect(find.byType(OnlineBooksHeader), findsNothing); - downloadsProvider.dispose(); }); testWidgets('starts visible polling on mount and stops it on provider swap and dispose', (tester) async { final firstProvider = _TrackingDownloadsProvider(gateway: _LibraryAcquisitionGateway()); final secondProvider = _TrackingDownloadsProvider(gateway: _LibraryAcquisitionGateway()); - await tester.pumpWidget(buildPage(downloadsProvider: firstProvider)); await tester.pump(); expect(firstProvider.libraryVisibility, [true]); - await tester.pumpWidget(buildPage(downloadsProvider: secondProvider)); await tester.pump(); expect(firstProvider.libraryVisibility, [true, false]); expect(secondProvider.libraryVisibility, [true]); - await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(secondProvider.libraryVisibility, [true, false]); - firstProvider.dispose(); secondProvider.dispose(); }); @@ -140,15 +126,13 @@ void main() { gateway: _LibraryAcquisitionGateway(), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); downloadsProvider.setServerManagedDownloadsReady(false); libraryProvider.setSearchQuery('Dune Messiah'); - await tester.pumpWidget(buildPage(screenSize: const Size(400, 1000), downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); - expect(find.text('Search online for “Dune Messiah”'), findsNothing); - downloadsProvider.dispose(); }); @@ -161,23 +145,19 @@ void main() { gateway: _LibraryAcquisitionGateway(), pollingInterval: Duration.zero, ); - await downloadsProvider.refreshConfiguration(); + await downloadsProvider.refreshConfiguration(); await tester.pumpWidget(buildPage(screenSize: const Size(400, 1000), downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); await tester.tap(find.byType(FloatingActionButton)); await tester.pumpAndSettle(); - expect(find.text('Find books online'), findsOneWidget); - await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); downloadsProvider.setServerManagedDownloadsReady(false); await tester.tap(find.byType(FloatingActionButton)); await tester.pumpAndSettle(); - expect(find.text('Find books online'), findsNothing); - downloadsProvider.dispose(); }); @@ -185,6 +165,7 @@ void main() { final gateway = _LibraryAcquisitionGateway(); final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); + libraryProvider ..setSearchQuery('Dune Messiah') ..setFavoriteFilter(FavoriteFilter.favorites) @@ -194,22 +175,18 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Dune Messiah”')); await tester.pumpAndSettle(); - expect(gateway.searchQueries, ['Dune Messiah']); expect(find.byType(OnlineBooksHeader), findsOneWidget); expect(find.byType(OnlineResultsView), findsOneWidget); expect(find.text('Remote result'), findsOneWidget); expect(find.byType(BookListItem), findsNothing); - await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); - expect(find.byType(OnlineBooksHeader), findsNothing); expect(libraryProvider.searchQuery, 'Dune Messiah'); expect(libraryProvider.favoriteFilter, FavoriteFilter.favorites); expect(libraryProvider.viewMode, LibraryViewMode.list); expect(gateway.searchQueries, hasLength(1)); - downloadsProvider.dispose(); }); @@ -219,30 +196,26 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Missing title'); - await tester.pumpWidget(buildPage(downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Missing title”')); await tester.pump(); - expect(find.byType(OnlineResultsView), findsOneWidget); expect(find.text('Searching connected sources for “Missing title”…'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.byType(BookCard), findsNothing); - searchCompleter.complete(const []); await tester.pumpAndSettle(); - expect(find.text('No releases found'), findsOneWidget); expect(find.textContaining('Missing title'), findsWidgets); expect(find.byType(CircularProgressIndicator), findsNothing); expect(find.byType(BookCard), findsNothing); - downloadsProvider.dispose(); }); testWidgets('Back invalidates a hung search so a new online session can search immediately', (tester) async { final oldSearch = Completer>(); + final gateway = _LibraryAcquisitionGateway( searchResponses: [ oldSearch.future, @@ -251,6 +224,7 @@ void main() { ], ], ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Old query'); @@ -261,32 +235,29 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Old query”')); await tester.pump(); expect(downloadsProvider.isSearching, isTrue); - await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); expect(downloadsProvider.isSearching, isFalse); - libraryProvider.setSearchQuery('New query'); await tester.pump(); await tester.tap(find.text('Search online for “New query”')); await tester.pumpAndSettle(); - expect(downloadsProvider.remoteQuery, 'New query'); expect(find.text('New result'), findsOneWidget); oldSearch.complete(const [ TorrentRelease(title: 'Old result', releaseToken: 'old-token', protocol: 'torrent', indexer: 'Prowlarr'), ]); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(downloadsProvider.remoteQuery, 'New query'); expect(find.text('New result'), findsOneWidget); expect(find.text('Old result'), findsNothing); - downloadsProvider.dispose(); }); @@ -294,7 +265,6 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1400, 1000); addTearDown(tester.view.reset); - final gateway = _LibraryAcquisitionGateway(); final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); @@ -306,16 +276,15 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.tap(find.byType(FloatingActionButton)); await tester.pumpAndSettle(); await tester.tap(find.text('Find books online')); await tester.pumpAndSettle(); - expect(find.byType(OnlineBooksHeader), findsOneWidget); expect(find.text('Search connected sources'), findsOneWidget); expect(gateway.searchQueries, isEmpty); - downloadsProvider.dispose(); }); @@ -324,6 +293,7 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Dune Messiah'); + final store = createTestDataStore( books: [Book(id: 'other-book', title: 'A different book', author: 'Author', addedAt: DateTime(2026))], ); @@ -334,28 +304,24 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byType(Checkbox).first); await tester.pump(); - expect(find.byType(SelectionHeader), findsOneWidget); expect(find.text('Select all'), findsOneWidget); - await tester.tap(find.text('Select all')); await tester.pump(); expect(downloadsProvider.selectedReleaseTokens, {'release-token', 'release-token-2'}); - await tester.tap(find.text('Download')); await tester.pumpAndSettle(); - expect(gateway.submittedTokens, ['release-token', 'release-token-2']); expect(find.byType(OnlineBooksHeader), findsNothing); expect(libraryProvider.searchQuery, isEmpty); expect(find.text('A different book'), findsWidgets); - downloadsProvider.dispose(); }); testWidgets('a reset releases a hung submission and its completion cannot reset the new request', (tester) async { final oldSubmission = Completer(); final newSubmission = Completer(); + final gateway = _LibraryAcquisitionGateway( submissionResponses: [oldSubmission.future, newSubmission.future], searchResponses: const [ @@ -363,6 +329,7 @@ void main() { [TorrentRelease(title: 'New result', releaseToken: 'new-token', protocol: 'torrent', indexer: 'Prowlarr')], ], ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Old query'); @@ -373,6 +340,7 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Old query”')); await tester.pumpAndSettle(); @@ -380,30 +348,24 @@ void main() { await tester.pump(); await tester.tap(find.text('Download')); await tester.pump(); - expect(downloadsProvider.isSubmitting, isTrue); - await tester.tap(find.byTooltip('Exit selection')); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); - expect(downloadsProvider.isSubmitting, isFalse); - libraryProvider.setSearchQuery('New query'); await tester.pump(); await tester.tap(find.text('Search online for “New query”')); await tester.pumpAndSettle(); - expect(downloadsProvider.remoteQuery, 'New query'); expect(find.text('New result'), findsOneWidget); - await tester.tap(find.byType(Checkbox)); await tester.pump(); await tester.tap(find.text('Download')); await tester.pump(); - expect(downloadsProvider.isSubmitting, isTrue); + expect(gateway.submittedTokenBatches, [ ['old-token'], ['new-token'], @@ -425,8 +387,8 @@ void main() { ], ), ); - await tester.pump(); + await tester.pump(); expect(find.byType(SelectionHeader), findsOneWidget); expect(downloadsProvider.remoteQuery, 'New query'); expect(find.text('New result'), findsOneWidget); @@ -449,26 +411,29 @@ void main() { ], ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(downloadsProvider.isSubmitting, isFalse); expect(find.byType(OnlineBooksHeader), findsNothing); expect(downloadsProvider.jobs.map((job) => job.id), ['new-submission']); - downloadsProvider.dispose(); }); testWidgets('a submission from a replaced provider cannot clear the current online state', (tester) async { final submission = Completer(); + final oldProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway(submissionCompleter: submission), pollingInterval: Duration.zero, ); + final currentProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway(), pollingInterval: Duration.zero, ); + await oldProvider.refreshConfiguration(); + currentProvider.setRemoteResults('Current query', const [ TorrentRelease( title: 'Current result', @@ -477,6 +442,7 @@ void main() { indexer: 'Prowlarr', ), ]); + libraryProvider.setSearchQuery('Old query'); await tester.pumpWidget( @@ -485,6 +451,7 @@ void main() { downloadsProvider: oldProvider, ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Old query”')); await tester.pumpAndSettle(); @@ -499,8 +466,8 @@ void main() { downloadsProvider: currentProvider, ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byType(OnlineBooksHeader), findsOneWidget); expect(find.text('Current result'), findsOneWidget); @@ -515,12 +482,11 @@ void main() { ], ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byType(OnlineBooksHeader), findsOneWidget); expect(currentProvider.remoteQuery, 'Current query'); expect(find.text('Current result'), findsOneWidget); - oldProvider.dispose(); currentProvider.dispose(); }); @@ -530,9 +496,9 @@ void main() { gateway: _LibraryAcquisitionGateway(releaseCount: 2), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Missing title'); - await tester.pumpWidget(buildPage(downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Missing title”')); @@ -541,18 +507,14 @@ void main() { await tester.pump(); await tester.tap(find.text('Select all')); await tester.pump(); - expect(downloadsProvider.selectedReleaseTokens, {'release-token', 'release-token-2'}); expect(find.text('Deselect all'), findsOneWidget); - await tester.tap(find.text('Deselect all')); await tester.pump(); - expect(downloadsProvider.selectedReleaseTokens, isEmpty); expect(find.byType(SelectionHeader), findsNothing); expect(find.byType(OnlineBooksHeader), findsOneWidget); expect(find.byType(Checkbox), findsNWidgets(2)); - downloadsProvider.dispose(); }); @@ -561,7 +523,6 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Dune Messiah'); - await tester.pumpWidget(buildPage(downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Dune Messiah”')); @@ -570,13 +531,11 @@ void main() { await tester.pump(); await tester.tap(find.text('Download')); await tester.pumpAndSettle(); - expect(find.byType(OnlineBooksHeader), findsNothing); expect(find.byType(SelectionHeader), findsOneWidget); expect(find.text('Remote result'), findsOneWidget); expect(find.text('This release could not be sent to the download client.'), findsOneWidget); expect(downloadsProvider.selectedReleaseTokens, {'release-token'}); - downloadsProvider.dispose(); }); @@ -593,6 +552,7 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Remote”')); await tester.pumpAndSettle(); @@ -602,22 +562,18 @@ void main() { await tester.pump(); await tester.tap(find.text('Download')); await tester.pumpAndSettle(); - expect(find.byType(SelectionHeader), findsOneWidget); expect(downloadsProvider.selectedReleaseTokens, {'release-token-2'}); expect(downloadsProvider.jobs.map((job) => job.id), contains('submitted-job-0')); expect(find.text('Remote result 2'), findsOneWidget); expect(find.text('This release could not be sent to the download client.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); - downloadsProvider.clearReleaseSelection(); await tester.pump(); await tester.tap(find.byTooltip('Back')); await tester.pumpAndSettle(); - expect(find.byType(AcquisitionPlaceholderCard), findsOneWidget); expect(find.text('Remote result'), findsWidgets); - downloadsProvider.dispose(); }); @@ -626,7 +582,6 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Dune Messiah'); - await tester.pumpWidget(buildPage(screenSize: const Size(400, 1000), downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Dune Messiah”')); @@ -635,18 +590,14 @@ void main() { await tester.pump(); await tester.tap(find.text('Download')); await tester.pumpAndSettle(); - expect(find.text('Download with'), findsOneWidget); expect(find.text('Download client 1'), findsOneWidget); expect(find.text('Download client 2'), findsOneWidget); expect(find.textContaining('/downloads'), findsNothing); - await tester.tap(find.text('Download client 2')); await tester.pumpAndSettle(); - expect(gateway.submittedEndpointId, 'client-2'); expect(find.byType(OnlineBooksHeader), findsNothing); - downloadsProvider.dispose(); }); @@ -655,24 +606,19 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('Dune Messiah'); - await tester.pumpWidget(buildPage(downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “Dune Messiah”')); await tester.pumpAndSettle(); - expect(find.text('Unable to search connected sources'), findsOneWidget); expect(find.text('Try again'), findsOneWidget); expect(gateway.searchQueries, ['Dune Messiah']); - gateway.failSearch = false; await tester.enterText(find.byType(TextField).first, 'Edited query'); await tester.tap(find.text('Try again')); await tester.pumpAndSettle(); - expect(gateway.searchQueries, ['Dune Messiah', 'Dune Messiah']); expect(find.text('Remote result'), findsOneWidget); - downloadsProvider.dispose(); }); @@ -689,15 +635,13 @@ void main() { downloadsProvider: downloadsProvider, ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); final filterChips = tester.widget(find.byType(LibraryFilterChips)); expect(filterChips.showDownloading, isTrue); expect(find.textContaining('Downloads'), findsNothing); - filterChips.onDownloadingTapped!(); await tester.pumpAndSettle(); - expect(find.byType(AcquisitionPlaceholderCard), findsOneWidget); expect(find.byType(BookCard), findsNothing); downloadsProvider.dispose(); @@ -710,6 +654,7 @@ void main() { Book(id: 'ordinary-book', title: 'Ordinary book', author: 'Author', addedAt: DateTime(2026)), ], ); + final downloadsProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway( jobs: [ @@ -723,23 +668,25 @@ void main() { ), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); await tester.pumpWidget( buildPage(screenSize: const Size(800, 1000), store: store, downloadsProvider: downloadsProvider), ); + await tester.pumpAndSettle(); final linkedCards = tester .widgetList(find.byType(BookCard)) .where((card) => card.acquisitionJob?.id == 'linked-job') .toList(); + expect(linkedCards, hasLength(1)); expect(find.byType(BookCard), findsNWidgets(2)); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); expect(find.text('Downloading 0%'), findsOneWidget); - downloadsProvider.dispose(); }); @@ -750,6 +697,7 @@ void main() { Book(id: 'ordinary-book', title: 'Ordinary book', author: 'Author', addedAt: DateTime(2026)), ], ); + final downloadsProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway( jobs: [ @@ -764,40 +712,43 @@ void main() { ), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); await tester.pumpWidget( buildPage(screenSize: const Size(800, 1000), store: store, downloadsProvider: downloadsProvider), ); + await tester.pumpAndSettle(); final filterChips = tester.widget(find.byType(LibraryFilterChips)); expect(filterChips.showDownloading, isTrue); - filterChips.onDownloadingTapped!(); await tester.pumpAndSettle(); - final visibleCards = tester.widgetList(find.byType(BookCard)).toList(); + final placeholders = tester .widgetList(find.byType(AcquisitionPlaceholderCard)) .toList(); + expect(visibleCards, hasLength(1)); expect(visibleCards.single.book.id, 'linked-book'); expect(placeholders, hasLength(1)); expect(placeholders.single.job.id, 'orphan-job'); expect(find.text('Ordinary book'), findsNothing); - downloadsProvider.dispose(); }); testWidgets('list view integrates linked and orphan jobs without duplication', (tester) async { libraryProvider.setViewMode(LibraryViewMode.list); + final store = createTestDataStore( books: [ Book(id: 'linked-book', title: 'Linked book', author: 'Author', addedAt: DateTime(2026)), Book(id: 'ordinary-book', title: 'Ordinary book', author: 'Author', addedAt: DateTime(2026)), ], ); + final downloadsProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway( jobs: [ @@ -812,19 +763,22 @@ void main() { ), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); await tester.pumpWidget( buildPage(screenSize: const Size(800, 1000), store: store, downloadsProvider: downloadsProvider), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); final bookItems = tester.widgetList(find.byType(BookListItem)).toList(); final linkedItems = bookItems.where((item) => item.acquisitionJob?.id == 'linked-job').toList(); + final placeholders = tester .widgetList(find.byType(AcquisitionPlaceholderListItem)) .toList(); + expect(bookItems, hasLength(2)); expect(linkedItems, hasLength(1)); expect(linkedItems.single.onAcquisitionTap, isNotNull); @@ -834,18 +788,15 @@ void main() { expect(placeholders.single.onTap, isNotNull); expect(placeholders.single.onEnterSelectionMode, isNotNull); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); - linkedItems.single.onAcquisitionTap!(); await tester.pumpAndSettle(); expect(find.byKey(const Key('acquisition-job-details-content')), findsOneWidget); - await tester.tapAt(const Offset(8, 8)); await tester.pumpAndSettle(); final orphanItem = tester.widget(find.byType(AcquisitionPlaceholderListItem)); orphanItem.onTap!(); await tester.pumpAndSettle(); expect(find.byKey(const Key('acquisition-job-details-content')), findsOneWidget); - downloadsProvider.dispose(); }); @@ -862,6 +813,7 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); final filterChips = tester.widget(find.byType(LibraryFilterChips)); expect(filterChips.showDownloading, isTrue); @@ -869,23 +821,18 @@ void main() { await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); - expect(find.byType(SelectionHeader), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); expect(find.text('Try again'), findsNothing); expect(find.text('Remove'), findsNothing); expect(libraryProvider.isSelectionMode, isFalse); - await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(find.byType(AlertDialog), findsOneWidget); - await tester.tap(find.text('Cancel downloads').last); await tester.pumpAndSettle(); - expect(gateway.cancelledJobIds, ['job-1']); expect(downloadsProvider.selectedJobIds, isEmpty); - downloadsProvider.dispose(); }); @@ -894,6 +841,7 @@ void main() { jobs: [_libraryJob(AcquisitionJobStatus.downloading, bookId: null)], cancelError: StateError('raw cancel failure at https://client.invalid?token=secret'), ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshJobs(); @@ -904,6 +852,7 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); @@ -911,13 +860,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Cancel downloads').last); await tester.pumpAndSettle(); - expect(find.text('Could not cancel the download. Try again.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); expect(find.textContaining('secret'), findsNothing); expect(downloadsProvider.selectedJobIds, {'job-1'}); expect(find.byType(SelectionHeader), findsOneWidget); - downloadsProvider.dispose(); }); @@ -929,6 +876,7 @@ void main() { Book(id: 'hidden', title: 'Hidden', author: 'Author', addedAt: DateTime(2026)), ], ); + final gateway = _LibraryAcquisitionGateway( jobs: [ _libraryJob(AcquisitionJobStatus.downloading, id: 'visible-job-1', bookId: 'visible-1'), @@ -936,6 +884,7 @@ void main() { _libraryJob(AcquisitionJobStatus.failed, id: 'hidden-job', bookId: 'hidden', retryable: true), ], ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshJobs(); libraryProvider.setSearchQuery('Visible'); @@ -943,22 +892,22 @@ void main() { await tester.pumpWidget( buildPage(screenSize: const Size(1200, 900), store: store, downloadsProvider: downloadsProvider), ); + await tester.pumpAndSettle(); final firstVisibleCard = tester .widgetList(find.byType(BookCard)) .firstWhere((card) => card.acquisitionJob?.id == 'visible-job-1'); + firstVisibleCard.onEnterSelectionMode!(); await tester.pump(); await tester.tap(find.text('Select all')); await tester.pump(); - expect(downloadsProvider.selectedJobIds, {'visible-job-1', 'visible-job-2'}); expect(find.text('2 selected'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Cancel'), findsOneWidget); expect(find.text('Try again'), findsNothing); expect(find.text('Remove'), findsNothing); - downloadsProvider.dispose(); }); @@ -966,62 +915,61 @@ void main() { final store = createTestDataStore( books: [Book(id: 'linked-book', title: 'Linked book', author: 'Author', addedAt: DateTime(2026))], ); + final downloadsProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway( jobs: [_libraryJob(AcquisitionJobStatus.downloading, id: 'linked-job', bookId: 'linked-book')], ), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshJobs(); await tester.pumpWidget( buildPage(screenSize: const Size(1200, 900), store: store, downloadsProvider: downloadsProvider), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); tester.widget(find.byType(BookCard)).onEnterSelectionMode!(); await tester.pump(); expect(downloadsProvider.selectedJobIds, {'linked-job'}); - libraryProvider.setStatusFilters({LibraryReadingStatus.completed}); await tester.pump(); await tester.pump(); - expect(find.byType(BookCard), findsNothing); expect(downloadsProvider.selectedJobIds, isEmpty); expect(find.byType(SelectionHeader), findsNothing); - downloadsProvider.dispose(); }); testWidgets('completed linked download is pruned from selection when it stops rendering', (tester) async { final jobs = [_libraryJob(AcquisitionJobStatus.downloading, id: 'linked-job', bookId: 'linked-book')]; + final store = createTestDataStore( books: [Book(id: 'linked-book', title: 'Linked book', author: 'Author', addedAt: DateTime(2026))], ); + final downloadsProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway(jobs: jobs), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshJobs(); await tester.pumpWidget( buildPage(screenSize: const Size(1200, 900), store: store, downloadsProvider: downloadsProvider), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); tester.widget(find.byType(BookCard)).onEnterSelectionMode!(); await tester.pump(); expect(downloadsProvider.selectedJobIds, {'linked-job'}); - jobs[0] = _libraryJob(AcquisitionJobStatus.completed, id: 'linked-job', bookId: 'linked-book'); await downloadsProvider.refreshJobs(); await tester.pump(); await tester.pump(); - expect(downloadsProvider.selectedJobIds, isEmpty); expect(find.byType(SelectionHeader), findsNothing); - downloadsProvider.dispose(); }); @@ -1039,23 +987,20 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); tester.semantics.tap(find.semantics.byLabel('Select Remote result')); await tester.pump(); - expect(find.byType(BottomSheet), findsNothing); expect(find.text('Remove'), findsOneWidget); expect(find.text('Cancel'), findsNothing); expect(find.text('Try again'), findsNothing); - await tester.tap(find.text('Remove')); await tester.pumpAndSettle(); expect(find.byType(AlertDialog), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Remove').last); await tester.pumpAndSettle(); - expect(gateway.removedJobIds, ['job-1']); - semantics.dispose(); downloadsProvider.dispose(); }); @@ -1065,6 +1010,7 @@ void main() { jobs: [_libraryJob(AcquisitionJobStatus.cancelled, bookId: null)], removeError: StateError('raw remove failure at https://client.invalid?token=secret'), ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshJobs(); @@ -1075,6 +1021,7 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); @@ -1082,13 +1029,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Remove').last); await tester.pumpAndSettle(); - expect(find.text('Could not remove the download. Try again.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); expect(find.textContaining('secret'), findsNothing); expect(downloadsProvider.selectedJobIds, {'job-1'}); expect(find.byType(SelectionHeader), findsOneWidget); - downloadsProvider.dispose(); }); @@ -1098,22 +1043,17 @@ void main() { final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); - await tester.pumpWidget(buildPage(screenSize: const Size(800, 1000), downloadsProvider: downloadsProvider)); await tester.pumpAndSettle(); final filterChips = tester.widget(find.byType(LibraryFilterChips)); filterChips.onDownloadingTapped!(); await tester.pumpAndSettle(); - expect(find.byType(BookCard), findsNothing); - jobs.clear(); await downloadsProvider.refreshJobs(); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(LibraryFilterChips)).showDownloading, isFalse); expect(find.byType(BookCard), findsWidgets); - downloadsProvider.dispose(); }); @@ -1122,6 +1062,7 @@ void main() { gateway: _LibraryAcquisitionGateway(jobs: [_libraryJob(AcquisitionJobStatus.downloading, bookId: null)]), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); @@ -1132,14 +1073,13 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.tap(find.byType(AcquisitionPlaceholderCard)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-job-details-content')), findsOneWidget); expect(find.text('Remote result'), findsWidgets); expect(find.textContaining('client-1'), findsNothing); - downloadsProvider.dispose(); }); @@ -1147,6 +1087,7 @@ void main() { final gateway = _LibraryAcquisitionGateway( jobs: [_libraryJob(AcquisitionJobStatus.failed, bookId: null, retryable: true)], ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); @@ -1158,29 +1099,28 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); - expect(find.text('Try again'), findsOneWidget); expect(find.text('Remove'), findsOneWidget); expect(find.text('Cancel'), findsNothing); - await tester.tap(find.text('Try again')); await tester.pumpAndSettle(); - expect(gateway.retriedJobIds, ['job-1']); expect(downloadsProvider.selectedJobIds, isEmpty); - downloadsProvider.dispose(); }); testWidgets('delayed bulk retry rejects a double tap and disables every bulk action', (tester) async { final retry = Completer(); + final gateway = _LibraryAcquisitionGateway( jobs: [_libraryJob(AcquisitionJobStatus.failed, bookId: null, retryable: true)], retryCompleter: retry, ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshJobs(); @@ -1191,35 +1131,33 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); - final retryButton = find.widgetWithText(FilledButton, 'Try again'); await tester.tap(retryButton); await tester.tap(retryButton); await tester.pump(); - expect(gateway.retriedJobIds, ['job-1']); expect(downloadsProvider.isMutatingJobs, isTrue); expect(tester.widget(retryButton).onPressed, isNull); expect(tester.widget(find.widgetWithText(FilledButton, 'Remove')).onPressed, isNull); - retry.complete(_libraryJob(AcquisitionJobStatus.downloading)); await tester.pumpAndSettle(); - expect(downloadsProvider.isMutatingJobs, isFalse); expect(downloadsProvider.selectedJobIds, isEmpty); - downloadsProvider.dispose(); }); testWidgets('delayed bulk removal disables its repeated destructive action', (tester) async { final removal = Completer(); + final gateway = _LibraryAcquisitionGateway( jobs: [_libraryJob(AcquisitionJobStatus.failed, bookId: null)], removeCompleter: removal, ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshJobs(); @@ -1230,6 +1168,7 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); @@ -1237,22 +1176,17 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Remove').last); await tester.pump(); - final removeButton = find.widgetWithText(FilledButton, 'Remove').first; expect(gateway.removedJobIds, ['job-1']); expect(downloadsProvider.isMutatingJobs, isTrue); expect(tester.widget(removeButton).onPressed, isNull); - await tester.tap(removeButton); await tester.pump(); expect(gateway.removedJobIds, ['job-1']); - removal.complete(); await tester.pumpAndSettle(); - expect(downloadsProvider.isMutatingJobs, isFalse); expect(downloadsProvider.selectedJobIds, isEmpty); - downloadsProvider.dispose(); }); @@ -1260,6 +1194,7 @@ void main() { tester, ) async { final retry = Completer(); + final oldProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway( jobs: [_libraryJob(AcquisitionJobStatus.failed, bookId: null, retryable: true)], @@ -1267,10 +1202,12 @@ void main() { ), pollingInterval: Duration.zero, ); + final currentProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway(), pollingInterval: Duration.zero, ); + await oldProvider.refreshJobs(); await tester.pumpWidget( @@ -1280,6 +1217,7 @@ void main() { downloadsProvider: oldProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); @@ -1293,15 +1231,13 @@ void main() { downloadsProvider: currentProvider, ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); retry.completeError(StateError('raw retry failure at https://client.invalid?token=secret')); await tester.pumpAndSettle(); - expect(find.text('Could not retry the download import. Try again.'), findsNothing); expect(find.textContaining('client.invalid'), findsNothing); expect(currentProvider.selectedJobIds, isEmpty); - oldProvider.dispose(); currentProvider.dispose(); }); @@ -1311,6 +1247,7 @@ void main() { jobs: [_libraryJob(AcquisitionJobStatus.failed, bookId: null, retryable: true)], retryError: StateError('raw retry failure at https://client.invalid?token=secret'), ); + final downloadsProvider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); await downloadsProvider.refreshJobs(); @@ -1321,68 +1258,65 @@ void main() { downloadsProvider: downloadsProvider, ), ); + await tester.pumpAndSettle(); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); await tester.pump(); await tester.tap(find.text('Try again')); await tester.pumpAndSettle(); - expect(find.text('Could not retry the download import. Try again.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); expect(find.textContaining('secret'), findsNothing); expect(downloadsProvider.selectedJobIds, {'job-1'}); expect(find.byType(SelectionHeader), findsOneWidget); - downloadsProvider.dispose(); }); testWidgets('completed job stays hidden before and after its synchronized book arrives', (tester) async { final store = createTestDataStore(books: []); + final downloadsProvider = AcquisitionDownloadsProvider( gateway: _LibraryAcquisitionGateway( jobs: [_libraryJob(AcquisitionJobStatus.completed, bookId: 'imported-book')], ), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); await downloadsProvider.refreshJobs(); await tester.pumpWidget( buildPage(screenSize: const Size(800, 1000), store: store, downloadsProvider: downloadsProvider), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); store.loadData( books: [Book(id: 'imported-book', title: 'Imported book', author: 'Author', addedAt: DateTime(2026))], ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); expect(find.text('Imported book'), findsWidgets); - downloadsProvider.dispose(); }); testWidgets('renders filter chips', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.byType(LibraryFilterChips), findsOneWidget); }); testWidgets('renders hamburger menu button', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.menu), findsOneWidget); }); testWidgets('renders FAB with add icon', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.byType(FloatingActionButton), findsOneWidget); expect(find.byIcon(Icons.add), findsOneWidget); }); @@ -1390,7 +1324,6 @@ void main() { testWidgets('renders grid view by default', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.byType(BookCard), findsWidgets); }); @@ -1398,7 +1331,6 @@ void main() { libraryProvider.setViewMode(LibraryViewMode.list); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(find.byType(BookListItem), findsWidgets); expect(find.byType(BookCard), findsNothing); }); @@ -1407,7 +1339,6 @@ void main() { final emptyStore = createTestDataStore(books: []); await tester.pumpWidget(buildPage(store: emptyStore)); await tester.pumpAndSettle(); - expect(find.byType(EmptyState), findsOneWidget); expect(find.text('No books found'), findsOneWidget); expect(find.text('Try adjusting your filters or add some books'), findsOneWidget); @@ -1417,14 +1348,12 @@ void main() { // ======================================================================== // Desktop layout tests // ======================================================================== - group('desktop layout', () { const desktopSize = Size(1200, 800); testWidgets('renders search bar', (tester) async { await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.byType(LibrarySearchBar), findsOneWidget); }); @@ -1433,37 +1362,33 @@ void main() { gateway: _LibraryAcquisitionGateway(), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); await tester.pumpWidget( buildPage(screenSize: const Size(1590, 1321), downloadsProvider: downloadsProvider, theme: AppTheme.dark), ); + await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField).first, 't'); await tester.pump(); - expect(tester.takeException(), isNull); - await tester.enterText(find.byType(TextField).first, 'Missing title'); await tester.pump(); - expect(find.text('Search online for “Missing title”'), findsOneWidget); expect(tester.takeException(), isNull); - downloadsProvider.dispose(); }); testWidgets('renders filter chips', (tester) async { await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.byType(LibraryFilterChips), findsOneWidget); }); testWidgets('renders "Add book" button', (tester) async { await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.text('Add book'), findsOneWidget); expect(find.text('Books'), findsNothing); expect(find.descendant(of: find.byType(LibraryToolbar), matching: find.text('Add book')), findsOneWidget); @@ -1473,7 +1398,6 @@ void main() { testWidgets('does not render FAB on desktop', (tester) async { await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.byType(FloatingActionButton), findsNothing); }); @@ -1489,7 +1413,6 @@ void main() { testWidgets('shows grid view by default on desktop', (tester) async { await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.byType(BookCard), findsWidgets); }); @@ -1497,7 +1420,6 @@ void main() { libraryProvider.setViewMode(LibraryViewMode.list); await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.byType(BookListItem), findsWidgets); }); @@ -1505,7 +1427,6 @@ void main() { final emptyStore = createTestDataStore(books: []); await tester.pumpWidget(buildPage(screenSize: desktopSize, store: emptyStore)); await tester.pumpAndSettle(); - expect(find.byType(EmptyState), findsOneWidget); }); @@ -1518,6 +1439,7 @@ void main() { gateway: _LibraryAcquisitionGateway(), pollingInterval: Duration.zero, ); + await downloadsProvider.refreshConfiguration(); libraryProvider.setSearchQuery('A deliberately long online book query'); @@ -1530,15 +1452,14 @@ void main() { textScaler: const TextScaler.linear(2), ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Search online for “A deliberately long online book query”')); await tester.pumpAndSettle(); - expect(find.byType(OnlineBooksHeader), findsOneWidget); expect(find.byType(OnlineResultsView), findsOneWidget); expect(find.text('Remote result'), findsOneWidget); expect(tester.takeException(), isNull); - downloadsProvider.dispose(); }); }); @@ -1546,12 +1467,10 @@ void main() { // ======================================================================== // Filtering tests // ======================================================================== - group('filtering', () { testWidgets('shows all books by default', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester), hasLength(5)); }); @@ -1559,7 +1478,6 @@ void main() { libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), unorderedEquals(['The Hobbit', 'Neuromancer'])); }); @@ -1567,7 +1485,6 @@ void main() { libraryProvider.setFavoriteFilter(FavoriteFilter.favorites); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), unorderedEquals(['The Hobbit', 'Foundation'])); }); @@ -1575,7 +1492,6 @@ void main() { libraryProvider.setStatusFilters({LibraryReadingStatus.completed}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), ['Dune']); }); @@ -1583,13 +1499,13 @@ void main() { libraryProvider.setStatusFilters({LibraryReadingStatus.unread}); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), unorderedEquals(['1984', 'Foundation'])); }); testWidgets('shows empty state when no books match filter', (tester) async { // Add a filter and clear all books libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); + final storeWithNoReadingBooks = createTestDataStore( books: [ Book( @@ -1601,9 +1517,9 @@ void main() { ), ], ); + await tester.pumpWidget(buildPage(store: storeWithNoReadingBooks)); await tester.pumpAndSettle(); - expect(find.byType(BookGrid), findsNothing); expect(find.byType(EmptyState), findsOneWidget); }); @@ -1612,7 +1528,6 @@ void main() { libraryProvider.setSearchQuery('Hobbit'); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), ['The Hobbit']); }); @@ -1620,7 +1535,6 @@ void main() { libraryProvider.setSearchQuery('Tolkien'); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), ['The Hobbit']); }); @@ -1628,10 +1542,8 @@ void main() { // Add a book-shelf relation dataStore.addBookToShelf('book-1', 'shelf-1'); libraryProvider.setShelfFilters({'shelf-1'}); - await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.id), ['book-1']); }); @@ -1639,10 +1551,8 @@ void main() { // Add a book-tag relation dataStore.addTagToBook('book-1', 'tag-1'); libraryProvider.setTopicFilters({'tag-1'}); - await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.id), ['book-1']); }); }); @@ -1650,7 +1560,6 @@ void main() { // ======================================================================== // View mode interaction tests // ======================================================================== - group('view mode switching', () { testWidgets('toggling view mode on mobile updates the display', (tester) async { await tester.pumpWidget(buildPage()); @@ -1662,7 +1571,6 @@ void main() { // Switch to list view libraryProvider.setViewMode(LibraryViewMode.list); await tester.pumpAndSettle(); - expect(find.byType(BookListItem), findsWidgets); expect(find.byType(BookCard), findsNothing); }); @@ -1671,7 +1579,6 @@ void main() { // ======================================================================== // List view specific tests // ======================================================================== - group('list view', () { testWidgets('list view shows items', (tester) async { libraryProvider.setViewMode(LibraryViewMode.list); @@ -1687,7 +1594,6 @@ void main() { libraryProvider.setViewMode(LibraryViewMode.list); // Toggle favorite for book-3 (originally not favorite) libraryProvider.toggleFavorite('book-3', false); - await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); @@ -1700,7 +1606,6 @@ void main() { libraryProvider.setViewMode(LibraryViewMode.list); await tester.pumpWidget(buildPage(screenSize: desktopSize)); await tester.pumpAndSettle(); - expect(find.byType(BookListItem), findsAtLeastNWidgets(1)); }); }); @@ -1708,12 +1613,10 @@ void main() { // ======================================================================== // Mobile drawer interaction // ======================================================================== - group('mobile drawer', () { testWidgets('opens drawer when hamburger menu is tapped', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.menu)); await tester.pumpAndSettle(); @@ -1726,7 +1629,6 @@ void main() { // ======================================================================== // Desktop compact layout // ======================================================================== - group('desktop compact layout', () { testWidgets('uses compact layout at narrow desktop width', (tester) async { // 850px is >= desktopSmall (840) but < 800 in maxWidth @@ -1744,7 +1646,6 @@ void main() { const wideDesktop = Size(1400, 800); await tester.pumpWidget(buildPage(screenSize: wideDesktop)); await tester.pumpAndSettle(); - expect(find.text('Add book'), findsOneWidget); }); }); @@ -1752,14 +1653,12 @@ void main() { // ======================================================================== // Multiple filter combinations // ======================================================================== - group('combined filters', () { testWidgets('reading + favorites shows intersection', (tester) async { libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); libraryProvider.setFavoriteFilter(FavoriteFilter.favorites); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester).map((book) => book.title), ['The Hobbit']); }); @@ -1767,7 +1666,6 @@ void main() { libraryProvider.setSearchQuery(''); await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); - expect(renderedGridBooks(tester), hasLength(5)); }); }); @@ -1885,6 +1783,7 @@ class _LibraryAcquisitionGateway implements AcquisitionDownloadsGateway { if (response is Future>) { return response; } + if (response is List) { return response; } @@ -1925,6 +1824,7 @@ class _LibraryAcquisitionGateway implements AcquisitionDownloadsGateway { if (response is Future) { return response; } + if (response is BatchSubmissionResponse) { return response; } @@ -1972,7 +1872,6 @@ class _LibraryAcquisitionGateway implements AcquisitionDownloadsGateway { } cancelledJobIds.add(jobId); - return _libraryJob(AcquisitionJobStatus.cancelled); } diff --git a/app/test/pages/profile_page_test.dart b/app/test/pages/profile_page_test.dart index 8ae1a7c1..71a75e22 100644 --- a/app/test/pages/profile_page_test.dart +++ b/app/test/pages/profile_page_test.dart @@ -22,10 +22,13 @@ import 'package:shared_preferences/shared_preferences.dart'; class _MemoryStorage implements RefreshTokenStorage { String? token; + @override Future delete() async => token = null; + @override Future read() async => token; + @override Future write(String val) async => token = val; } @@ -58,6 +61,7 @@ class _MockAuthRepo extends AuthRepository { class _OfflineConnector extends PowerSyncBackendConnector { @override Future fetchCredentials() async => null; + @override Future uploadData(PowerSyncDatabase database) async {} } @@ -92,6 +96,7 @@ void main() { authProvider = AuthProvider(prefs, repository: _MockAuthRepo(), bootstrapOnCreate: false); await authProvider.bootstrap(); powerSync = _MockPowerSyncService(); + syncSettings = SyncSettingsProvider( prefs, officialConfig: PapyrusApiConfig( @@ -99,6 +104,7 @@ void main() { powerSyncServiceUri: Uri.parse('https://data-sync.test'), ), ); + dataStore = DataStore(); uploadQueue = MediaUploadQueue(prefs); prefsProvider = PreferencesProvider(prefs); @@ -129,45 +135,32 @@ void main() { testWidgets('mobile layout renders all sections and displays logout dialog', (tester) async { await tester.binding.setSurfaceSize(const Size(400, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget(buildApp(screenSize: const Size(400, 900))); await tester.pumpAndSettle(); - expect(find.text('Profile'), findsOneWidget); expect(find.text('Reader Profile'), findsOneWidget); expect(find.text('reader@example.com'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Account'), 300); expect(find.text('Change password'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Appearance'), 300); expect(find.text('Theme'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Reading'), 300); expect(find.text('Default font'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Library'), 300); expect(find.text('Default view'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Notifications'), 300); expect(find.text('Goal reminders'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Storage'), 300); expect(find.text('Clear local library'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Privacy & data'), 300); expect(find.text('Analytics'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'Accessibility'), 300); expect(find.text('Reduce animations'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(SettingsSectionHeader, 'About'), 300); expect(find.text('Version'), findsOneWidget); - await tester.scrollUntilVisible(find.widgetWithText(ProfileMenuItem, 'Log out'), 300); await tester.tap(find.widgetWithText(ProfileMenuItem, 'Log out')); await tester.pumpAndSettle(); - expect(find.text('Are you sure you want to log out?'), findsOneWidget); await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); @@ -177,7 +170,6 @@ void main() { testWidgets('desktop layout navigates across all sections correctly', (tester) async { await tester.binding.setSurfaceSize(const Size(1200, 900)); addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget(buildApp(screenSize: const Size(1200, 900))); await tester.pumpAndSettle(); diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 6df9a418..b49aa274 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -110,14 +110,18 @@ void main() { Future buildAuthProvider({bool guest = false, bool signedIn = false}) async { final prefs = await SharedPreferences.getInstance(); final repository = _FakeAuthRepository(); + if (signedIn) { repository.bootstrapResult = _tokens(); } + final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); await provider.bootstrap(); + if (guest) { provider.setOfflineMode(true); } + return provider; } @@ -131,6 +135,7 @@ void main() { AcquisitionAvailabilityProvider? acquisitionAvailabilityProvider, }) async { final prefs = await SharedPreferences.getInstance(); + final config = PapyrusApiConfig( serverBaseUri: Uri.parse('https://api.test'), powerSyncServiceUri: Uri.parse('https://data-sync.test'), @@ -174,12 +179,10 @@ void main() { testWidgets('offline storage sync UI is local-first and hides sync internals', (tester) async { final auth = await buildAuthProvider(guest: true); final service = _FakePowerSyncService(currentMode: LibraryDatabaseMode.guest, currentSyncState: const SyncState()); - await tester.pumpWidget(await buildPage(authProvider: auth, powerSyncService: service)); await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('Storage'), 400); await tester.pumpAndSettle(); - expect(find.text('Stored on this device'), findsOneWidget); expect(find.text('Export or import a backup'), findsOneWidget); expect(find.text('Clear local library'), findsOneWidget); @@ -207,24 +210,26 @@ void main() { testWidgets('authenticated mobile profile separates supported acquisition settings from storage', (tester) async { final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), ); + final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: true)); await availability.refresh(Uri.parse('https://api.test')); await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), ); + await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.byKey(const Key('profile-acquisition-section')), 400); await tester.pumpAndSettle(); - final acquisitionSection = find.byKey(const Key('profile-acquisition-section')); - expect(find.descendant(of: acquisitionSection, matching: find.text('Data sync')), findsNothing); expect(find.descendant(of: acquisitionSection, matching: find.text('Acquisition')), findsOneWidget); + expect( find.descendant( of: acquisitionSection, @@ -232,18 +237,16 @@ void main() { ), findsOneWidget, ); + expect(find.descendant(of: acquisitionSection, matching: find.text('Available on this server')), findsOneWidget); expect(find.descendant(of: acquisitionSection, matching: find.text('Manage integrations')), findsNothing); - final semantics = tester.getSemantics(find.bySemanticsLabel('Enable acquisition')); expect(semantics.label, 'Enable acquisition'); expect(semantics.flagsCollection.isEnabled, Tristate.isTrue); expect(semantics.flagsCollection.isToggled, Tristate.isFalse); expect(semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); await tester.pumpAndSettle(); - expect(tester.getSemantics(find.bySemanticsLabel('Enable acquisition')).flagsCollection.isToggled, Tristate.isTrue); expect(find.descendant(of: acquisitionSection, matching: find.text('Manage integrations')), findsOneWidget); }); @@ -251,55 +254,54 @@ void main() { testWidgets('authenticated mobile profile reports pending acquisition availability', (tester) async { final completer = Completer(); final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), ); + final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) => completer.future); final refresh = availability.refresh(Uri.parse('https://api.test')); await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), ); + await tester.pump(); await tester.scrollUntilVisible(find.byKey(const Key('profile-acquisition-section')), 400); await tester.pump(); - expect(find.text('Checking server support…'), findsOneWidget); - completer.complete(_capabilities(enabled: true)); await refresh; await tester.pumpAndSettle(); - expect(find.text('Available on this server'), findsOneWidget); }); testWidgets('authenticated mobile profile hides acquisition management when server is unavailable', (tester) async { final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), ); + final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: false)); await availability.refresh(Uri.parse('https://api.test')); await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, acquisitionAvailabilityProvider: availability), ); + await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('Storage'), 400); await tester.pumpAndSettle(); - final acquisitionSection = find.byKey(const Key('profile-acquisition-section')); - expect(acquisitionSection, findsOneWidget); expect(find.text('Enable acquisition'), findsOneWidget); expect(find.text('Unavailable on this server'), findsOneWidget); expect(find.text('Manage integrations'), findsNothing); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); await tester.pumpAndSettle(); - expect(find.text('Manage integrations'), findsNothing); }); @@ -310,10 +312,10 @@ void main() { await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), ); + await tester.pump(); await tester.tap(find.text('Storage').first); await tester.pump(); - expect(find.text('Library storage'), findsOneWidget); expect(find.text('Your library is stored on this device.'), findsOneWidget); expect(find.textContaining('Nothing is sent to Papyrus servers'), findsOneWidget); @@ -336,14 +338,17 @@ void main() { testWidgets('authenticated storage sync UI shows data sync and hides implementation details', (tester) async { final auth = await buildAuthProvider(signedIn: true); + final dataStore = dataStoreWithBooks([ testBook(id: 'book-1', title: 'Small book', fileSize: 100 * 1024 * 1024), testBook(id: 'book-2', title: 'Large book', fileSize: 250 * 1024 * 1024), ]); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: SyncState(connected: true, lastSyncedAt: DateTime.utc(2026, 6, 27, 10, 30)), ); + final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: true)); await availability.refresh(Uri.parse('https://api.test')); @@ -356,10 +361,10 @@ void main() { acquisitionAvailabilityProvider: availability, ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Storage').first); await tester.pumpAndSettle(); - expect(find.text('Data sync'), findsOneWidget); expect(find.text('Official server'), findsWidgets); expect(find.text('350 MB used, 674 MB available of 1 GB'), findsOneWidget); @@ -383,26 +388,24 @@ void main() { expect(find.text('Media storage'), findsNothing); expect(find.text('Local database'), findsNothing); expect(find.text('Server-scoped account cache'), findsNothing); - await tester.ensureVisible(find.text('Reconnect')); await tester.pumpAndSettle(); await tester.tap(find.text('Reconnect')); await tester.pump(); - expect(service.reconnectCalls, 1); - await tester.tap(find.bySemanticsLabel('Enable acquisition')); await tester.pumpAndSettle(); - expect(find.text('Manage integrations'), findsOneWidget); }); testWidgets('authenticated desktop profile reports unsupported acquisition separately', (tester) async { final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), ); + final availability = AcquisitionAvailabilityProvider(loadCapabilities: (_) async => _capabilities(enabled: false)); await availability.refresh(Uri.parse('https://api.test')); @@ -414,10 +417,10 @@ void main() { acquisitionAvailabilityProvider: availability, ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Storage').first); await tester.pumpAndSettle(); - expect(find.byKey(const Key('profile-data-sync-card')), findsOneWidget); expect(find.byKey(const Key('profile-acquisition-card')), findsOneWidget); expect(find.text('Unavailable on this server'), findsOneWidget); @@ -426,6 +429,7 @@ void main() { testWidgets('manage servers lists official and custom servers for switching', (tester) async { final prefs = await SharedPreferences.getInstance(); + final syncSettings = SyncSettingsProvider( prefs, officialConfig: PapyrusApiConfig( @@ -437,9 +441,11 @@ void main() { fileStorageQuotaBytes: 1_073_741_824, ), ); + await syncSettings.addCustomServer('https://reader.example'); syncSettings.selectServer(SyncSettingsProvider.officialServerId); final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true), @@ -448,12 +454,12 @@ void main() { await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, syncSettingsProvider: syncSettings), ); + await tester.pumpAndSettle(); await tester.scrollUntilVisible(find.text('Storage'), 400); await tester.pumpAndSettle(); await tester.tap(find.text('Manage servers')); await tester.pumpAndSettle(); - expect(find.text('Sync servers'), findsOneWidget); expect(find.text('Official server'), findsWidgets); expect(find.text('reader.example'), findsOneWidget); @@ -462,6 +468,7 @@ void main() { testWidgets('storage sync UI shows pending writes and sync errors', (tester) async { final auth = await buildAuthProvider(signedIn: true); + final service = _FakePowerSyncService( currentMode: LibraryDatabaseMode.authenticated, currentSyncState: const SyncState(connected: true, hasPendingWrites: true, uploadError: 'upload failed'), @@ -470,10 +477,10 @@ void main() { await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Storage').first); await tester.pumpAndSettle(); - expect(find.text('Error'), findsWidgets); expect(find.text('Sync error: upload failed'), findsOneWidget); expect(find.text('Pending changes'), findsNothing); diff --git a/app/test/pages/reader_page_test.dart b/app/test/pages/reader_page_test.dart index b4335f7d..b89e5359 100644 --- a/app/test/pages/reader_page_test.dart +++ b/app/test/pages/reader_page_test.dart @@ -18,14 +18,17 @@ void main() { testWidgets('host theme changes keep the same open document and avoid rereading media', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + final dataStore = DataStore() ..loadData( books: [buildTestBook(id: 'epub-book', fileFormat: BookFormat.epub)], ); + final cache = _ReaderMediaCache(); addTearDown(dataStore.dispose); late StateSetter updateTheme; var dark = false; + await tester.pumpWidget( MultiProvider( providers: [ @@ -38,6 +41,7 @@ void main() { child: StatefulBuilder( builder: (context, setState) { updateTheme = setState; + return MaterialApp( theme: ThemeData(brightness: dark ? Brightness.dark : Brightness.light), home: const ReaderPage(bookId: 'epub-book'), @@ -46,9 +50,11 @@ void main() { ), ), ); + for (var frame = 0; frame < 8 && find.byType(PapyrusReader).evaluate().isEmpty; frame++) { await tester.pump(); } + expect(cache.loads, 1, reason: tester.widgetList(find.byType(Text)).map((text) => text.data).join(' / ')); expect(find.byType(PapyrusReader), findsOneWidget); final before = tester.widget(find.byType(PapyrusReader)).document; @@ -59,13 +65,16 @@ void main() { expect(cache.loads, 1); await tester.pumpWidget(const SizedBox.shrink()); }); + testWidgets('explains when a book format is not supported', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + final dataStore = DataStore() ..loadData( books: [buildTestBook(id: 'mobi-book', fileFormat: BookFormat.mobi)], ); + addTearDown(dataStore.dispose); await tester.pumpWidget( @@ -77,14 +86,15 @@ void main() { child: const MaterialApp(home: ReaderPage(bookId: 'mobi-book')), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.text('This book format is not supported yet.'), findsOneWidget); }); } class _ReaderMediaCache extends MediaCacheService { int loads = 0; + @override Future ensureBookFileCached( Book book, { @@ -100,6 +110,7 @@ class _ReaderMediaCache extends MediaCacheService { class _ReaderAuth extends ChangeNotifier implements AuthProvider { @override Future downloadMedia(String assetId) async => Uint8List(0); + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/app/test/pages/reader_theme_test.dart b/app/test/pages/reader_theme_test.dart index 6daaca85..2afa583b 100644 --- a/app/test/pages/reader_theme_test.dart +++ b/app/test/pages/reader_theme_test.dart @@ -16,6 +16,7 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); + final controller = ReaderController( initialPreferences: ReaderPreferences(brightness: reading), registry: ReaderEngineRegistry([ @@ -25,7 +26,9 @@ void main() { ), ]), ); + addTearDown(controller.dispose); + await tester.pumpWidget( MaterialApp( theme: switch (host) { @@ -39,18 +42,21 @@ void main() { ), ), ); + await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Reading settings')); await tester.pumpAndSettle(); final label = find.text('Reading mode'); final readerTheme = Theme.of(tester.element(label)); final readerColors = readerTheme.colorScheme; + if (host == 'eink') { expect(readerTheme.extension()!.reduceAnimations, isTrue); expect(readerTheme.inputDecorationTheme.hintFadeDuration, Duration.zero); expect(readerTheme.filledButtonTheme.style!.animationDuration, Duration.zero); expect(readerTheme.splashFactory, same(NoSplash.splashFactory)); } + final labelText = tester.widget(find.descendant(of: label, matching: find.byType(RichText)).first); expect(labelText.text.style!.color, readerColors.onSurfaceVariant); await tester.tap(find.text('Paginated').first); @@ -58,15 +64,19 @@ void main() { final option = find.text('Continuous scroll').last; final menuTheme = Theme.of(tester.element(option)); expect(menuTheme.canvasColor, readerColors.surface); + final optionText = tester.widget( find.descendant(of: option, matching: find.byType(RichText)).first, ); + final foreground = optionText.text.style!.color!.computeLuminance(); final background = menuTheme.canvasColor.computeLuminance(); + expect( (math.max(foreground, background) + .05) / (math.min(foreground, background) + .05), greaterThanOrEqualTo(4.5), ); + await tester.tap(option); await tester.pumpAndSettle(); expect(controller.preferences.layoutMode, ReaderLayoutMode.scroll); @@ -80,16 +90,20 @@ void main() { final changedTheme = Theme.of(tester.element(columns)); expect(changedTheme.brightness, reading == Brightness.light ? Brightness.dark : Brightness.light); expect(changedTheme.canvasColor, changedTheme.colorScheme.surface); + final columnsText = tester.widget( find.descendant(of: columns, matching: find.byType(RichText)).first, ); + final changedForeground = columnsText.text.style!.color!.computeLuminance(); final changedBackground = changedTheme.canvasColor.computeLuminance(); + expect( (math.max(changedForeground, changedBackground) + .05) / (math.min(changedForeground, changedBackground) + .05), greaterThanOrEqualTo(4.5), ); + await tester.tap(columns); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -103,10 +117,13 @@ void main() { final class _ThemePdfFacade implements PdfFacade { @override int get pageCount => 3; + @override List get outline => const []; + @override Future showPage(int pageIndex, double pageOffset) async {} + @override Widget buildViewport(PdfViewportConfiguration configuration) => const SizedBox.expand(); } diff --git a/app/test/pages/shelves_grid_test.dart b/app/test/pages/shelves_grid_test.dart index af72eadf..44b0275a 100644 --- a/app/test/pages/shelves_grid_test.dart +++ b/app/test/pages/shelves_grid_test.dart @@ -28,14 +28,17 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = layout.screen; addTearDown(tester.view.reset); + final store = createTestDataStore( books: [], shelves: List.generate(20, (i) => buildTestShelf(id: 'shelf-$i', name: 'Shelf $i with a long title')), ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); store.dispose(); }); + await tester.pumpWidget( createTestPage( dataStore: store, @@ -52,14 +55,17 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final provider = tester.element(find.byType(ShelvesFilterChips)).read(); final search = tester.getRect(find.byType(TextField)); final gutter = layout.screen.width >= Breakpoints.desktopSmall ? Spacing.lg : Spacing.md; final options = bookGridSizeOptions(layout.width - gutter * 2); + if (layout.screen.width < Breakpoints.desktopSmall) { expect(options.last.columns, 4); } + for (final option in options) { provider.setGridItemWidth(option.preferredWidth); await tester.pumpAndSettle(); @@ -69,19 +75,24 @@ void main() { final first = tester.getRect(cards.first); expect(first.left, closeTo(search.left, .001)); expect(first.top - tester.getRect(find.byType(ShelvesFilterChips)).bottom, Spacing.sm); + final row = cards .evaluate() - .map((e) => tester.getRect(find.byWidget(e.widget))) - .where((r) => r.top == first.top) + .map((item) => tester.getRect(find.byWidget(item.widget))) + .where((item) => item.top == first.top) .toList(); + expect(row.length, option.columns); expect(row.last.right, closeTo(layout.screen.width - gutter, .001)); final spacing = bookGridLayout(layout.width - gutter * 2, itemWidth: option.preferredWidth).spacing; + for (var i = 1; i < row.length; i++) { expect(row[i].left - row[i - 1].right, closeTo(spacing, .001)); } + expect(tester.takeException(), isNull); } + provider.setViewMode(LibraryViewMode.list); await tester.pumpAndSettle(); final firstShelf = find.byType(ShelfCard).first; @@ -98,15 +109,18 @@ void main() { (screen: 1200.0, width: 560.0), ]) { final width = layout.width; + testWidgets('shelf view sheet applies real columns and retains density at $width', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = Size(layout.screen, 1000); addTearDown(tester.view.reset); final store = createTestDataStore(); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); store.dispose(); }); + await tester.pumpWidget( createTestPage( dataStore: store, @@ -117,13 +131,16 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final provider = tester.element(find.byType(ShelvesFilterChips)).read(); + await tester.scrollUntilVisible( find.byTooltip('Change view mode'), 200, scrollable: find.descendant(of: find.byType(ShelvesFilterChips), matching: find.byType(Scrollable)).first, ); + await tester.ensureVisible(find.byTooltip('Change view mode')); await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Change view mode')); @@ -132,17 +149,20 @@ void main() { expect(find.text('Large grid'), findsNothing); final gutter = layout.screen >= Breakpoints.desktopSmall ? Spacing.lg : Spacing.md; final options = bookGridSizeOptions(width - gutter * 2); + for (final option in options) { final label = '${option.columns} ${option.columns == 1 ? 'column' : 'columns'}'; await tester.tap(find.widgetWithText(ChoiceChip, label)); await tester.pumpAndSettle(); expect(tester.widget(find.widgetWithText(ChoiceChip, label)).selected, isTrue); + expect( (tester.widget(find.byType(GridView)).gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) .crossAxisCount, option.columns, ); } + final density = provider.gridItemWidth; await tester.tap(find.text('List').last); await tester.pumpAndSettle(); @@ -158,6 +178,7 @@ void main() { expect(tester.takeException(), isNull); }); } + testWidgets('compact shelf mosaics with missing images fit at enlarged text', (tester) async { for (var covers = 1; covers <= 4; covers++) { await tester.pumpWidget( @@ -186,6 +207,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull, reason: '$covers-cover mosaic'); } diff --git a/app/test/platform/book_import_drop_registration_web_test.dart b/app/test/platform/book_import_drop_registration_web_test.dart index aa0a16ae..ccbf65c3 100644 --- a/app/test/platform/book_import_drop_registration_web_test.dart +++ b/app/test/platform/book_import_drop_registration_web_test.dart @@ -10,9 +10,7 @@ void main() { web.window.ondrop = null; web.window.ondragenter = null; web.window.ondragover = null; - ensureBookImportDropPluginRegistered(); - expect(web.window.ondrop, isNotNull); expect(web.window.ondragenter, isNotNull); expect(web.window.ondragover, isNotNull); diff --git a/app/test/powersync/bookmark_persistence_test.dart b/app/test/powersync/bookmark_persistence_test.dart index 5b408f19..87a8960c 100644 --- a/app/test/powersync/bookmark_persistence_test.dart +++ b/app/test/powersync/bookmark_persistence_test.dart @@ -22,22 +22,26 @@ void main() { colorHex: '#2196F3', createdAt: DateTime.utc(2026), ); + expect(bookmarkRowMapper.fromRow(bookmarkRowMapper.toRow(bookmark)).toJson(), bookmark.toJson()); }); for (final guest in [true, false]) { test('bookmarks persist, merge, clear, react, and isolate in ${guest ? 'guest' : 'account'} storage', () async { final directory = await Directory.systemTemp.createTemp('papyrus-bookmarks-'); + PapyrusPowerSyncService open() => PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, pathResolver: (mode, profile, user) async => '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', ); + Future activate(PapyrusPowerSyncService db) => guest ? db.activateGuest() : db.activateAuthenticated('one'); var db = open(); await activate(db); await db.upsert(buildTestBook(id: 'book', isPhysical: true)); + final bookmark = Bookmark( id: 'mark', bookId: 'book', @@ -47,6 +51,7 @@ void main() { note: 'Remember', createdAt: DateTime.utc(2026), ); + await db.bookmarks.upsert(bookmark); await db.watchLibrary().firstWhere((snapshot) => snapshot.bookmarks.isNotEmpty); final store = DataStore(bookRepository: db); diff --git a/app/test/powersync/library_live_sync_test.dart b/app/test/powersync/library_live_sync_test.dart index 608e1b02..2d3c0af5 100644 --- a/app/test/powersync/library_live_sync_test.dart +++ b/app/test/powersync/library_live_sync_test.dart @@ -19,10 +19,13 @@ import 'package:uuid/uuid.dart'; class _MemoryRefreshStorage implements RefreshTokenStorage { String? token; + @override Future read() async => token; + @override Future write(String refreshToken) async => token = refreshToken; + @override Future delete() async => token = null; } @@ -33,7 +36,11 @@ class _DiagnosticClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { final response = await _client.send(request); - if (response.statusCode < 400 || !request.url.path.endsWith('/powersync-upload')) return response; + + if (response.statusCode < 400 || !request.url.path.endsWith('/powersync-upload')) { + return response; + } + final body = await response.stream.toBytes(); stderr.writeln('Sync upload failed (${response.statusCode}): ${utf8.decode(body)}'); return http.StreamedResponse(Stream.value(body), response.statusCode, headers: response.headers, request: request); @@ -45,10 +52,15 @@ class _DiagnosticClient extends http.BaseClient { Future _eventually(Future Function() condition, String description) async { final deadline = DateTime.now().add(const Duration(seconds: 90)); + while (DateTime.now().isBefore(deadline)) { - if (await condition()) return; + if (await condition()) { + return; + } + await Future.delayed(const Duration(milliseconds: 100)); } + fail('Timed out: $description'); } @@ -59,23 +71,28 @@ void main() { final directory = await Directory.systemTemp.createTemp('papyrus-live-library-'); final client = _DiagnosticClient(); final config = PapyrusApiConfig(serverBaseUri: Uri.parse('http://localhost:8080')); + AuthRepository auth() => AuthRepository( apiClient: AuthApiClient(config: config, httpClient: client), tokenStore: TokenStore(_MemoryRefreshStorage()), ); + final firstAuth = auth(); final secondAuth = auth(); final otherAuth = auth(); final suffix = const Uuid().v4(); final email = 'sync-check-$suffix@example.com'; final password = 'SyncCheck-$suffix'; + final owner = await firstAuth.register( email: email, password: password, displayName: 'Sync validation', clientType: 'desktop', ); + await secondAuth.login(email: email, password: password, clientType: 'desktop'); + final other = await otherAuth.register( email: 'other-$email', password: password, @@ -85,6 +102,7 @@ void main() { PapyrusPowerSyncService device(String name, AuthRepository repository, {bool connect = true}) { late PapyrusPowerSyncService result; + result = PapyrusPowerSyncService( connectAuthenticated: connect, connectorFactory: () => PapyrusPowerSyncConnector( @@ -94,6 +112,7 @@ void main() { ), pathResolver: (_, _, _) async => '${directory.path}/$name.db', ); + return result; } @@ -107,10 +126,12 @@ void main() { final annotationId = const Uuid().v4(); final bookmarkId = const Uuid().v4(); final now = DateTime.now().toUtc(); + try { await first.activateAuthenticated(owner.user.userId); await second.activateAuthenticated(owner.user.userId); await outsider.activateAuthenticated(other.user.userId); + await first.upsert( Book( id: bookId, @@ -122,9 +143,11 @@ void main() { physicalLocation: 'Room A', ), ); + await first.shelves.upsert(Shelf(id: shelfId, name: 'Shelf', createdAt: now, updatedAt: now)); await first.tags.upsert(Tag(id: tagId, name: 'Topic', colorHex: '#123456', createdAt: now)); await first.notes.upsert(Note(id: noteId, bookId: bookId, title: 'Note', content: 'Original', createdAt: now)); + await first.bookmarks.upsert( Bookmark( id: bookmarkId, @@ -136,6 +159,7 @@ void main() { createdAt: now, ), ); + await first.annotations.upsert( Annotation( id: annotationId, @@ -146,33 +170,38 @@ void main() { createdAt: now, ), ); + await first.memberships.updateMemberships(bookIds: {bookId}, shelfIds: [shelfId], tagIds: [tagId]); await _eventually( () async => await second.bookTags.getById('$bookId:$tagId') != null, 'all domain rows reach device two', ); + expect((await second.shelves.getById(shelfId))?.name, 'Shelf'); expect((await second.tags.getById(tagId))?.name, 'Topic'); expect((await second.annotations.getById(annotationId))?.note, 'Attached'); expect((await second.getById(bookId))?.publicationDate, DateTime.utc(2020)); expect(await second.bookShelves.getById('$bookId:$shelfId'), isNotNull); + await _eventually( () async => await second.bookmarks.getById(bookmarkId) != null, 'bookmark reaches device two', ); - expect((await second.bookmarks.getById(bookmarkId))?.pageNumber, 15); + expect((await second.bookmarks.getById(bookmarkId))?.pageNumber, 15); await second.setOnline(false); final baselineNote = (await second.notes.getById(noteId))!; final baselineBook = (await second.getById(bookId))!; final baselineBookmark = (await second.bookmarks.getById(bookmarkId))!; await second.bookmarks.upsert(baselineBookmark.copyWith(note: null), previous: baselineBookmark); await second.notes.upsert(baselineNote.copyWith(content: 'Offline edit'), previous: baselineNote); + await second.scopedBooks.update( baselineBook.copyWith(physicalLocation: 'Room B', isFavorite: true), previous: baselineBook, ); + await second.close(); second = device('two', secondAuth, connect: false); await second.activateAuthenticated(owner.user.userId); @@ -187,20 +216,25 @@ void main() { await first.bookmarks.upsert(onlineBookmark.copyWith(colorHex: '#2196F3'), previous: onlineBookmark); await _eventually(() async => !first.syncState.hasPendingWrites, 'online writes upload'); await second.setOnline(true); + await _eventually( () async => (await first.notes.getById(noteId))?.content == 'Offline edit', 'offline writes upload after restart', ); + await _eventually( () async => (await second.notes.getById(noteId))?.title == 'Remote title', 'different fields merge', ); + expect((await first.getById(bookId))?.physicalLocation, 'Room B'); await _eventually(() async => (await second.getById(bookId))?.lentTo == 'Reader', 'promoted book fields merge'); + await _eventually( () async => (await first.getById(bookId))?.isFavorite == true, 'favorite syncs after restart', ); + await _eventually( () async => (await first.bookmarks.getById(bookmarkId))?.note == null && @@ -217,17 +251,19 @@ void main() { await first.scopedBooks.update(beforeClear.copyWith(clearLentTo: true), previous: beforeClear); await _eventually(() async => !first.syncState.hasPendingWrites, 'first competing edit uploads'); await second.setOnline(true); + await _eventually( () async => (await first.notes.getById(noteId))?.content == 'Last accepted', 'same-field conflicts follow server acceptance order', ); - await _eventually(() async => (await second.getById(bookId))?.lentTo == null, 'explicit null clears remotely'); + await _eventually(() async => (await second.getById(bookId))?.lentTo == null, 'explicit null clears remotely'); final topic = (await first.tags.getById(tagId))!; await first.tags.upsert(topic.copyWith(name: 'Renamed topic'), previous: topic); final annotation = (await first.annotations.getById(annotationId))!; await first.annotations.upsert(annotation.copyWith(note: 'Edited attachment'), previous: annotation); await first.memberships.updateMemberships(bookIds: {bookId}, shelfIds: [], previousShelfIds: {shelfId}); + await _eventually( () async => await second.bookShelves.getById('$bookId:$shelfId') == null && @@ -245,7 +281,6 @@ void main() { expect(await outsider.bookmarks.getById(bookmarkId), isNull); expect(await outsider.bookShelves.getById('$bookId:$shelfId'), isNull); expect(await outsider.bookTags.getById('$bookId:$tagId'), isNull); - await second.setOnline(false); final stale = (await second.notes.getById(noteId))!; await second.notes.upsert(stale.copyWith(content: 'Stale after deletion'), previous: stale); @@ -254,15 +289,19 @@ void main() { await first.delete(bookId); await _eventually(() async => !first.syncState.hasPendingWrites, 'book deletion uploads'); await second.setOnline(true); + await _eventually( () async => await second.getById(bookId) == null && await second.notes.getById(noteId) == null, 'deletion wins against offline edit', ); + expect(await second.annotations.getById(annotationId), isNull); + await _eventually( () async => await second.bookmarks.getById(bookmarkId) == null, 'bookmark deletion defeats stale edits', ); + expect(await second.bookShelves.getById('$bookId:$shelfId'), isNull); expect(await second.bookTags.getById('$bookId:$tagId'), isNull); await _eventually(() async => !second.syncState.hasPendingWrites, 'stale write queue drains'); @@ -273,6 +312,7 @@ void main() { await first.close(); await second.close(); await outsider.close(); + for (final repository in [firstAuth, otherAuth]) { final response = await client.delete( config.endpoint('/users/me'), @@ -282,8 +322,10 @@ void main() { }, body: jsonEncode({'password': password}), ); + expect(response.statusCode, 204); } + client.close(); await directory.delete(recursive: true); } diff --git a/app/test/powersync/library_persistence_test.dart b/app/test/powersync/library_persistence_test.dart index 5b834c3b..4edb68ea 100644 --- a/app/test/powersync/library_persistence_test.dart +++ b/app/test/powersync/library_persistence_test.dart @@ -31,6 +31,7 @@ void main() { test('schema expansion keeps queued legacy books and promotes local metadata once', () async { final dbPath = '${directory.path}/official-one.db'; + final legacy = PowerSyncDatabase( path: dbPath, schema: const Schema([ @@ -42,7 +43,9 @@ void main() { ]), ]), ); + await legacy.initialize(); + await legacy.execute('INSERT INTO books (id, title, author, added_at, custom_metadata) VALUES (?, ?, ?, ?, ?)', [ 'book', 'Legacy', @@ -55,6 +58,7 @@ void main() { 'custom_metadata': {'preserved': 'yes'}, }), ]); + final before = await legacy.getAll('SELECT data FROM ps_crud'); await legacy.close(); final upgraded = service(); @@ -80,9 +84,11 @@ void main() { final db = service(); await db.activateGuest(); await db.upsert(Book(id: 'book', title: 'Book', author: 'Author', addedAt: now)); + for (final id in ['a', 'b', 'c']) { await db.shelves.upsert(Shelf(id: id, name: id, createdAt: now, updatedAt: now)); } + await db.memberships.updateMemberships(bookIds: {'book'}, shelfIds: ['a', 'b']); await db.memberships.updateMemberships(bookIds: {'book'}, shelfIds: ['c'], previousShelfIds: {'a'}); expect(await db.bookShelves.getById('book:a'), isNull); @@ -98,6 +104,7 @@ void main() { final dbPath = '${directory.path}/official-one.db'; final cached = PowerSyncDatabase(path: dbPath, schema: papyrusAccountSchema); await cached.initialize(); + await cached.execute( 'INSERT INTO books (id, title, author, added_at, physical_location, custom_metadata) VALUES (?, ?, ?, ?, ?, ?)', [ @@ -114,6 +121,7 @@ void main() { }), ], ); + final count = (await cached.getAll('SELECT data FROM ps_crud')).length; await cached.close(); final upgraded = service(); @@ -149,6 +157,7 @@ void main() { await first.shelves.upsert(Shelf(id: 'shelf', name: 'Shelf', createdAt: now, updatedAt: now)); await first.tags.upsert(Tag(id: 'tag', name: 'Topic', colorHex: '#123456', createdAt: now)); await first.notes.upsert(Note(id: 'note', bookId: 'book', title: 'Note', content: 'Content', createdAt: now)); + await first.annotations.upsert( Annotation( id: 'annotation', @@ -158,9 +167,9 @@ void main() { createdAt: now, ), ); + await first.bookShelves.upsert(BookShelfRelation(bookId: 'book', shelfId: 'shelf', addedAt: now)); await first.close(); - final second = service(); await second.activateGuest(); expect((await second.shelves.getById('shelf'))?.name, 'Shelf'); diff --git a/app/test/powersync/papyrus_schema_mode_test.dart b/app/test/powersync/papyrus_schema_mode_test.dart index 5de1374f..0e65afa7 100644 --- a/app/test/powersync/papyrus_schema_mode_test.dart +++ b/app/test/powersync/papyrus_schema_mode_test.dart @@ -12,6 +12,7 @@ void main() { 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/powersync_book_mapper_test.dart b/app/test/powersync/powersync_book_mapper_test.dart index cc4a2a1f..9792887a 100644 --- a/app/test/powersync/powersync_book_mapper_test.dart +++ b/app/test/powersync/powersync_book_mapper_test.dart @@ -17,6 +17,7 @@ void main() { expect(PowerSyncBookMapper.toRow(book)['cover_image_url'], isNull); } }); + test('partial uploads preserve absent JSON fields and explicit nulls', () { expect(PowerSyncBookMapper.decodeUploadData({'title': 'Only title'}), {'title': 'Only title'}); expect(PowerSyncBookMapper.decodeUploadData({'custom_metadata': null}), {'custom_metadata': null}); @@ -24,6 +25,7 @@ void main() { test('every portable book field survives a complete row round trip', () { final date = DateTime.utc(2026, 9, 5); + final original = Book( id: 'book', title: 'Title', @@ -65,6 +67,7 @@ void main() { completedAt: date, lastReadAt: date, ); + final restored = PowerSyncBookMapper.fromRow(PowerSyncBookMapper.toRow(original)); expect(restored.toJson(), original.toJson()); }); @@ -76,6 +79,7 @@ void main() { 'is_physical': 0, 'custom_metadata': jsonEncode({'physical_location': 'Old', 'is_physical': true}), }); + expect(book.physicalLocation, isNull); expect(book.isPhysical, isFalse); }); @@ -102,7 +106,6 @@ void main() { ); final row = PowerSyncBookMapper.toRow(book); - expect(row['cover_image_url'], isNull); expect(row['file_media_id'], '22222222-2222-2222-2222-222222222222'); expect(row['cover_media_id'], '33333333-3333-3333-3333-333333333333'); diff --git a/app/test/powersync/powersync_service_test.dart b/app/test/powersync/powersync_service_test.dart index cf83d375..d0d6e7db 100644 --- a/app/test/powersync/powersync_service_test.dart +++ b/app/test/powersync/powersync_service_test.dart @@ -26,10 +26,8 @@ void main() { test('pending refresh does not supersede a transport status event', () { final revisions = SyncStateRevisionCoordinator(); final transportRevision = revisions.beginTransportUpdate(); - expect(revisions.observeForPendingRefresh(), transportRevision); expect(revisions.isCurrent(transportRevision), isTrue); - revisions.beginTransportUpdate(); expect(revisions.isCurrent(transportRevision), isFalse); }); @@ -38,12 +36,9 @@ void main() { final updates = StreamController.broadcast(sync: true); addTearDown(updates.close); var current = const SyncState(); - current = const SyncState(connected: true); updates.add(current); - final received = await streamWithCurrentValue(currentValue: () => current, updates: updates.stream).first; - expect(received.connected, isTrue); }); @@ -73,16 +68,15 @@ void main() { await first.activateGuest(); await first.upsert(_book('guest-book')); await first.close(); - final second = service(); await second.activateGuest(); - expect((await second.getById('guest-book'))?.title, 'Persistent guest book'); await second.close(); }); test('getById waits for database activation', () async { final allowPathResolution = Completer(); + final first = PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, @@ -95,7 +89,6 @@ void main() { final activation = first.activateGuest(); final lookup = first.getById('missing-book'); allowPathResolution.complete(); - await activation; expect(await lookup, isNull); await first.close(); @@ -104,6 +97,7 @@ void main() { test('activation emits the database snapshot instead of a synthetic empty snapshot', () async { final allowPathResolution = Completer(); final firstSnapshot = Completer>(); + final first = PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, @@ -112,18 +106,19 @@ void main() { return path.join(directory.path, 'snapshot-guest.db'); }, ); + final subscription = first.watchAll().listen((books) { - if (!firstSnapshot.isCompleted) firstSnapshot.complete(books); + if (!firstSnapshot.isCompleted) { + firstSnapshot.complete(books); + } }); final activation = first.activateGuest(); await Future.delayed(Duration.zero); - expect(firstSnapshot.isCompleted, isFalse); allowPathResolution.complete(); await activation; expect(await firstSnapshot.future, isEmpty); - await subscription.cancel(); await first.close(); }); @@ -134,10 +129,8 @@ void main() { await first.upsert(_book('account-book')); await first.deactivate(); await first.close(); - final second = service(); await second.activateAuthenticated('user-one'); - expect(await second.getById('account-book'), isNull); await second.close(); }); @@ -145,9 +138,7 @@ void main() { test('authenticated writes expose the affected book as pending', () async { final first = service(); await first.activateAuthenticated('user-one'); - await first.upsert(_book('account-book')); - expect(first.bookMetadataSyncState.pendingBookIds, contains('account-book')); await first.close(); }); @@ -156,9 +147,7 @@ void main() { final first = service(); await first.activateGuest(); await first.upsert(_book('guest-book')); - await first.clearGuestLibrary(); - expect(await first.getById('guest-book'), isNull); await first.close(); }); @@ -167,9 +156,7 @@ void main() { final first = service(); await first.activateAuthenticated('user-one'); await first.upsert(_book('account-book')); - await first.clearAuthenticatedCache(); - expect(first.mode, LibraryDatabaseMode.authenticated); expect(await first.getById('account-book'), isNull); await first.close(); @@ -178,7 +165,6 @@ void main() { test('reconnect requires an authenticated database', () async { final first = service(); await first.activateGuest(); - expect(first.reconnect(), throwsStateError); await first.close(); }); @@ -187,14 +173,10 @@ void main() { final first = service(); await first.activateAuthenticated('user-one', profileKey: 'official'); await first.upsert(_book('official-book')); - await first.activateAuthenticated('user-one', profileKey: 'custom-local'); - expect(await first.getById('official-book'), isNull); await first.upsert(_book('custom-book')); - await first.activateAuthenticated('user-one', profileKey: 'official'); - expect((await first.getById('official-book'))?.title, 'Persistent guest book'); expect(await first.getById('custom-book'), isNull); await first.close(); diff --git a/app/test/powersync/storage_sync_controller_test.dart b/app/test/powersync/storage_sync_controller_test.dart index 54e9e0d7..a0f71a14 100644 --- a/app/test/powersync/storage_sync_controller_test.dart +++ b/app/test/powersync/storage_sync_controller_test.dart @@ -72,6 +72,7 @@ void main() { final prefs = await SharedPreferences.getInstance(); final authProvider = AuthProvider(prefs, repository: _FakeAuthRepository(), bootstrapOnCreate: false); await authProvider.bootstrap(); + final controller = StorageSyncController( authProvider: authProvider, powerSyncService: _FakePowerSyncService(), diff --git a/app/test/powersync/sync_profile_switch_queue_test.dart b/app/test/powersync/sync_profile_switch_queue_test.dart index c69185e9..2e1db015 100644 --- a/app/test/powersync/sync_profile_switch_queue_test.dart +++ b/app/test/powersync/sync_profile_switch_queue_test.dart @@ -19,14 +19,13 @@ void main() { }), isTrue, ); + await firstStarted.future; expect(queue.requestedProfileKey, 'profile-b'); - expect(queue.request('profile-a', () async => applied.add('profile-a')), isTrue); expect(queue.requestedProfileKey, 'profile-a'); releaseFirst.complete(); await queue.waitUntilIdle(); - expect(applied, ['profile-b', 'profile-a']); expect(errors, isEmpty); }); @@ -34,11 +33,9 @@ void main() { test('failed switch does not prevent the next request', () async { final errors = []; final queue = SyncProfileSwitchQueue(initialProfileKey: 'profile-a', onError: (error, _) => errors.add(error)); - queue.request('profile-b', () async => throw StateError('unavailable')); queue.request('profile-c', () async {}); await queue.waitUntilIdle(); - expect(queue.requestedProfileKey, 'profile-c'); expect(errors, hasLength(1)); }); diff --git a/app/test/powersync/tracking_persistence_test.dart b/app/test/powersync/tracking_persistence_test.dart index bd221fbb..50472466 100644 --- a/app/test/powersync/tracking_persistence_test.dart +++ b/app/test/powersync/tracking_persistence_test.dart @@ -17,8 +17,10 @@ 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); } @@ -27,6 +29,7 @@ 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, @@ -35,6 +38,7 @@ void main() { endTime: now.add(const Duration(seconds: 10)), createdAt: now.add(const Duration(seconds: 10)), ); + final goal = ReadingGoal( id: 'goal', type: GoalType.minutes, @@ -44,50 +48,70 @@ void main() { 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'); + 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); + + final snapshot = await db.watchLibrary().firstWhere( + (item) => item.activities.isNotEmpty && item.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); + + expect( + (await db.watchLibrary().firstWhere((item) => item.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(); @@ -97,12 +121,13 @@ void main() { await first.close(); final second = service(); await second.activateGuest(); - final snapshot = await second.watchLibrary().firstWhere((s) => s.activities.isNotEmpty); + final snapshot = await second.watchLibrary().firstWhere((item) => item.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); @@ -124,6 +149,7 @@ void main() { 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(); @@ -132,11 +158,13 @@ void main() { 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); @@ -147,6 +175,7 @@ void main() { expect((await library.snapshot()).activities.single.id, activity.id); await db.close(); }); + test('v1 keeps book sets local across restart while ordinary tracking uploads', () async { final path = '${directory.path}/book-sets.db'; final db = PowerSyncDatabase(path: path, schema: papyrusAccountSchema); @@ -154,11 +183,13 @@ void main() { 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); @@ -167,7 +198,7 @@ void main() { 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 supported.snapshot()).goals.firstWhere((goal) => goal.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(); @@ -177,6 +208,7 @@ void main() { 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(); @@ -186,11 +218,13 @@ void main() { 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); @@ -200,6 +234,7 @@ void main() { 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(); @@ -209,18 +244,20 @@ void main() { 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); + final empty = await db.watchLibrary().firstWhere((item) => item.activities.isEmpty && item.books.isEmpty); expect(empty.goals, isEmpty); await db.activateGuest(); - final restored = await db.watchLibrary().firstWhere((s) => s.activities.isNotEmpty); + final restored = await db.watchLibrary().firstWhere((item) => item.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, @@ -230,10 +267,12 @@ void main() { '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', @@ -242,12 +281,15 @@ void main() { 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], @@ -258,9 +300,10 @@ void main() { ), 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'}); + final snapshot = await db.watchLibrary().firstWhere((item) => item.activities.length == 2); + expect(snapshot.activities.map((activity) => activity.id).toSet(), {'reading', 'rollback'}); await db.close(); }); } diff --git a/app/test/providers/auth_provider_test.dart b/app/test/providers/auth_provider_test.dart index bc0e7294..c028953f 100644 --- a/app/test/providers/auth_provider_test.dart +++ b/app/test/providers/auth_provider_test.dart @@ -70,23 +70,21 @@ void main() { final prefs = await SharedPreferences.getInstance(); final repository = FakeAuthRepository()..bootstrapResult = _tokens('Bootstrap User'); final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); - await provider.bootstrap(); - expect(provider.isSignedIn, isTrue); expect(provider.user?.displayName, 'Bootstrap User'); }); test('clears auth state when refresh fails', () async { final prefs = await SharedPreferences.getInstance(); + final repository = FakeAuthRepository() ..bootstrapResult = _tokens('Bootstrap User') ..refreshError = const AuthApiException(statusCode: 401, message: 'Invalid refresh token'); - final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); await provider.bootstrap(); final refreshed = await provider.refresh(); - expect(refreshed, isFalse); expect(provider.isSignedIn, isFalse); expect(provider.user, isNull); @@ -96,10 +94,8 @@ void main() { final prefs = await SharedPreferences.getInstance(); final repository = FakeAuthRepository()..bootstrapResult = _tokens('Bootstrap User'); final provider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); - await provider.bootstrap(); provider.setOfflineMode(true); - expect(provider.isOfflineMode, isTrue); expect(provider.isSignedIn, isFalse); expect(repository.clearCalled, isTrue); @@ -110,10 +106,8 @@ void main() { final officialRepository = FakeAuthRepository()..bootstrapResult = _tokens('Official User'); final customRepository = FakeAuthRepository()..bootstrapResult = _tokens('Custom User'); final provider = AuthProvider(prefs, repository: officialRepository, bootstrapOnCreate: false); - await provider.bootstrap(); await provider.replaceRepository(customRepository); - expect(officialRepository.clearCalled, isFalse); expect(provider.isSignedIn, isTrue); expect(provider.user?.displayName, 'Custom User'); diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index 91febc76..ac46ad84 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -18,6 +18,7 @@ void main() { setUp(() { provider = BookDetailsProvider(); dataStore = DataStore(); + dataStore.loadData( books: [ buildTestBook( @@ -39,6 +40,7 @@ void main() { annotations: [buildTestAnnotation(id: 'ann-1', bookId: 'book-1', selectedText: 'Highlight 1')], notes: [buildTestNote(id: 'note-1', bookId: 'book-1', title: 'Note 1', content: 'Content 1')], ); + provider.setDataStore(dataStore); }); @@ -85,9 +87,7 @@ void main() { final startupStore = DataStore(bookRepository: repository); final startupProvider = BookDetailsProvider()..setDataStore(startupStore); addTearDown(startupProvider.dispose); - await startupProvider.loadBook(storedBook.id); - expect(startupProvider.book, storedBook); expect(startupProvider.error, isNull); }); @@ -98,16 +98,12 @@ void main() { final startupStore = DataStore(bookRepository: repository); final startupProvider = BookDetailsProvider()..setDataStore(startupStore); addTearDown(startupProvider.dispose); - final load = startupProvider.loadBook(storedBook.id); await Future.delayed(Duration.zero); - expect(startupProvider.isLoading, isTrue); expect(startupProvider.error, isNull); - repository.controller.add([storedBook]); await load; - expect(startupProvider.book, storedBook); expect(startupProvider.error, isNull); await startupStore.disposeBookRepository(); @@ -116,7 +112,6 @@ void main() { test('loads a book from DataStore', () async { await provider.loadBook('book-1'); - expect(provider.hasBook, true); expect(provider.book!.id, 'book-1'); expect(provider.book!.title, 'Test Book'); @@ -126,7 +121,6 @@ void main() { test('sets error when book not found', () async { await provider.loadBook('nonexistent'); - expect(provider.hasBook, false); expect(provider.error, isNotNull); expect(provider.error, contains('Book not found')); @@ -134,7 +128,6 @@ void main() { test('loads bookmarks for the book', () async { await provider.loadBook('book-1'); - expect(provider.bookmarks.length, 2); expect(provider.hasBookmarks, true); expect(provider.bookmarkCount, 2); @@ -142,7 +135,6 @@ void main() { test('loads annotations for the book', () async { await provider.loadBook('book-1'); - expect(provider.annotations.length, 1); expect(provider.hasAnnotations, true); expect(provider.annotationCount, 1); @@ -150,7 +142,6 @@ void main() { test('loads notes for the book', () async { await provider.loadBook('book-1'); - expect(provider.notes.length, 1); expect(provider.hasNotes, true); expect(provider.noteCount, 1); @@ -159,10 +150,8 @@ void main() { test('does not reload same book unnecessarily', () async { await provider.loadBook('book-1'); final firstBook = provider.book; - var notifyCount = 0; provider.addListener(() => notifyCount++); - await provider.loadBook('book-1'); // Should be the same object since the book didn't change @@ -174,7 +163,6 @@ void main() { test('notifies listeners during load', () async { var notifyCount = 0; provider.addListener(() => notifyCount++); - await provider.loadBook('book-1'); // At least 2 notifications: loading start + loading complete @@ -186,20 +174,16 @@ void main() { test('changes selected tab', () { provider.setTab(BookDetailsTab.bookmarks); expect(provider.selectedTab, BookDetailsTab.bookmarks); - provider.setTab(BookDetailsTab.annotations); expect(provider.selectedTab, BookDetailsTab.annotations); - provider.setTab(BookDetailsTab.notes); expect(provider.selectedTab, BookDetailsTab.notes); }); test('does not notify when setting same tab', () { provider.setTab(BookDetailsTab.bookmarks); - var notified = false; provider.addListener(() => notified = true); - provider.setTab(BookDetailsTab.bookmarks); expect(notified, false); }); @@ -207,7 +191,6 @@ void main() { test('notifies when changing tab', () { var notified = false; provider.addListener(() => notified = true); - provider.setTab(BookDetailsTab.notes); expect(notified, true); }); @@ -217,13 +200,10 @@ void main() { test('sets tab by index', () { provider.setTabIndex(0); expect(provider.selectedTab, BookDetailsTab.details); - provider.setTabIndex(1); expect(provider.selectedTab, BookDetailsTab.bookmarks); - provider.setTabIndex(2); expect(provider.selectedTab, BookDetailsTab.annotations); - provider.setTabIndex(3); expect(provider.selectedTab, BookDetailsTab.notes); }); @@ -232,7 +212,6 @@ void main() { provider.setTab(BookDetailsTab.details); provider.setTabIndex(-1); expect(provider.selectedTab, BookDetailsTab.details); - provider.setTabIndex(10); expect(provider.selectedTab, BookDetailsTab.details); }); @@ -241,10 +220,8 @@ void main() { group('toggleDescriptionExpanded', () { test('toggles expanded state', () { expect(provider.isDescriptionExpanded, false); - provider.toggleDescriptionExpanded(); expect(provider.isDescriptionExpanded, true); - provider.toggleDescriptionExpanded(); expect(provider.isDescriptionExpanded, false); }); @@ -266,14 +243,11 @@ void main() { test('addNote persists to DataStore and notifies', () async { final note = buildTestNote(id: 'new-note', bookId: 'book-1', title: 'New Note', content: 'New content'); - var notified = false; provider.addListener(() => notified = true); - await provider.addNote(note); - expect(provider.notes.length, 2); - expect(provider.notes.any((n) => n.id == 'new-note'), true); + expect(provider.notes.any((note) => note.id == 'new-note'), true); expect(dataStore.getNote('new-note'), isNotNull); expect(notified, true); }); @@ -287,13 +261,11 @@ void main() { ); await provider.updateNote('note-1', updatedNote); - expect(dataStore.getNote('note-1')!.title, 'Updated Title'); }); test('deleteNote removes from DataStore', () async { await provider.deleteNote('note-1'); - expect(provider.notes, isEmpty); expect(dataStore.getNote('note-1'), isNull); }); @@ -306,16 +278,13 @@ void main() { test('addBookmark persists to DataStore', () async { final bookmark = buildTestBookmark(id: 'new-bm', bookId: 'book-1', position: 0.8); - await provider.addBookmark(bookmark); - expect(provider.bookmarks.length, 3); expect(dataStore.getBookmark('new-bm'), isNotNull); }); test('updateBookmarkNote updates the bookmark note', () async { await provider.updateBookmarkNote('bm-1', 'Updated note'); - expect(dataStore.getBookmark('bm-1')!.note, 'Updated note'); }); @@ -331,13 +300,11 @@ void main() { test('updateBookmarkColor updates the bookmark color', () async { await provider.updateBookmarkColor('bm-1', '#FF0000'); - expect(dataStore.getBookmark('bm-1')!.colorHex, '#FF0000'); }); test('deleteBookmark removes from DataStore', () async { await provider.deleteBookmark('bm-1'); - expect(provider.bookmarks.length, 1); expect(dataStore.getBookmark('bm-1'), isNull); }); @@ -345,9 +312,7 @@ void main() { test('updateBookmarkNote does nothing for nonexistent bookmark', () async { var notified = false; provider.addListener(() => notified = true); - await provider.updateBookmarkNote('nonexistent', 'A note'); - expect(notified, false); }); }); @@ -359,16 +324,13 @@ void main() { test('addAnnotation persists to DataStore', () async { final annotation = buildTestAnnotation(id: 'new-ann', bookId: 'book-1', selectedText: 'New highlight'); - await provider.addAnnotation(annotation); - expect(provider.annotations.length, 2); expect(dataStore.getAnnotation('new-ann'), isNotNull); }); test('updateAnnotationNote updates the annotation note', () async { await provider.updateAnnotationNote('ann-1', 'Updated note'); - expect(dataStore.getAnnotation('ann-1')!.note, 'Updated note'); }); @@ -381,14 +343,12 @@ void main() { ); await provider.updateAnnotation('ann-1', updated); - expect(dataStore.getAnnotation('ann-1')!.selectedText, 'Replaced text'); expect(dataStore.getAnnotation('ann-1')!.color, HighlightColor.pink); }); test('deleteAnnotation removes from DataStore', () async { await provider.deleteAnnotation('ann-1'); - expect(provider.annotations, isEmpty); expect(dataStore.getAnnotation('ann-1'), isNull); }); @@ -396,9 +356,7 @@ void main() { test('updateAnnotationNote does nothing for nonexistent annotation', () async { var notified = false; provider.addListener(() => notified = true); - await provider.updateAnnotationNote('nonexistent', 'A note'); - expect(notified, false); }); }); @@ -407,14 +365,10 @@ void main() { test('toggles favorite status and persists', () async { await provider.loadBook('book-1'); expect(provider.book!.isFavorite, false); - provider.toggleFavorite(); - expect(provider.book!.isFavorite, true); expect(dataStore.getBook('book-1')!.isFavorite, true); - provider.toggleFavorite(); - expect(provider.book!.isFavorite, false); expect(dataStore.getBook('book-1')!.isFavorite, false); }); @@ -422,9 +376,7 @@ void main() { test('does nothing when no book loaded', () { var notified = false; provider.addListener(() => notified = true); - provider.toggleFavorite(); - expect(notified, false); }); }); @@ -432,19 +384,15 @@ void main() { group('updateProgress', () { test('updates position and persists', () async { await provider.loadBook('book-1'); - provider.updateProgress(0.75); - expect(provider.book!.currentPosition, 0.75); expect(dataStore.getBook('book-1')!.currentPosition, 0.75); }); test('clamps value to 0.0-1.0', () async { await provider.loadBook('book-1'); - provider.updateProgress(1.5); expect(provider.book!.currentPosition, 1.0); - provider.updateProgress(-0.5); expect(provider.book!.currentPosition, 0.0); }); @@ -452,9 +400,7 @@ void main() { test('does nothing when no book loaded', () { var notified = false; provider.addListener(() => notified = true); - provider.updateProgress(0.5); - expect(notified, false); }); }); @@ -462,9 +408,7 @@ void main() { group('updatePageProgress', () { test('updates page and position', () async { await provider.loadBook('book-1'); - provider.updatePageProgress(150, 0.5); - expect(provider.book!.currentPage, 150); expect(provider.book!.currentPosition, 0.5); expect(provider.book!.lastReadAt, isNotNull); @@ -472,43 +416,33 @@ void main() { test('reaching the end keeps completion explicit', () async { await provider.loadBook('book-1'); - provider.updatePageProgress(300, 1.0); - expect(provider.book!.readingStatus, LibraryReadingStatus.inProgress); }); test('sets status to inProgress when position > 0', () async { // Load a not-started book await provider.loadBook('book-2'); - provider.updatePageProgress(10, 0.1); - expect(provider.book!.readingStatus, LibraryReadingStatus.inProgress); }); test('clamps position to 0.0-1.0', () async { await provider.loadBook('book-1'); - provider.updatePageProgress(999, 2.0); - expect(provider.book!.currentPosition, 1.0); }); test('does nothing when no book loaded', () { var notified = false; provider.addListener(() => notified = true); - provider.updatePageProgress(10, 0.1); - expect(notified, false); }); test('persists to DataStore', () async { await provider.loadBook('book-1'); - provider.updatePageProgress(200, 0.67); - final stored = dataStore.getBook('book-1')!; expect(stored.currentPage, 200); expect(stored.currentPosition, 0.67); @@ -534,9 +468,7 @@ void main() { await provider.loadBook('book-1'); provider.setTab(BookDetailsTab.notes); provider.toggleDescriptionExpanded(); - provider.clear(); - expect(provider.book, isNull); expect(provider.hasBook, false); expect(provider.selectedTab, BookDetailsTab.details); @@ -550,10 +482,8 @@ void main() { test('switches to new DataStore and removes old listener', () { final oldStore = DataStore(); oldStore.loadData(books: [buildTestBook(id: 'old-book')]); - final newStore = DataStore(); newStore.loadData(books: [buildTestBook(id: 'new-book')]); - final p = BookDetailsProvider(); p.setDataStore(oldStore); p.setDataStore(newStore); @@ -561,7 +491,6 @@ void main() { // Updating old store should not affect provider // (no crash = listener was properly removed) oldStore.addBook(buildTestBook(id: 'another')); - p.dispose(); }); diff --git a/app/test/providers/book_edit_persistence_test.dart b/app/test/providers/book_edit_persistence_test.dart index 0e99a6a8..86b26d71 100644 --- a/app/test/providers/book_edit_persistence_test.dart +++ b/app/test/providers/book_edit_persistence_test.dart @@ -17,12 +17,14 @@ void main() { setUp(() async { directory = await Directory.systemTemp.createTemp('papyrus-editor-'); + service = PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, pathResolver: (mode, profile, user) async => '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', ); + await service.activateGuest(); await service.upsert(buildTestBook(id: 'book', title: 'Original', author: 'Original author')); store = DataStore(bookRepository: service); @@ -41,7 +43,6 @@ void main() { final original = editor.originalBook!; editor.updateTitle('Edited title'); await service.scopedBooks.update(original.copyWith(author: 'Remote author'), previous: original); - expect(await editor.save(), isTrue); final saved = await service.getById('book'); expect(saved?.title, 'Edited title'); @@ -52,7 +53,6 @@ void main() { editor.updateTitle('Stale edit'); await service.activateAuthenticated('other-account'); await service.upsert(buildTestBook(id: 'book', title: 'Other account')); - expect(await editor.save(), isFalse); expect(editor.error, contains('Failed to save book')); expect((await service.getById('book'))?.title, 'Other account'); diff --git a/app/test/providers/book_edit_provider_test.dart b/app/test/providers/book_edit_provider_test.dart index 2c4e4b3e..7419d15f 100644 --- a/app/test/providers/book_edit_provider_test.dart +++ b/app/test/providers/book_edit_provider_test.dart @@ -14,9 +14,7 @@ void main() { final storedBook = buildTestBook(id: 'repository-book', title: 'Stored Book'); final dataStore = DataStore(bookRepository: _LookupBookRepository(storedBook)); final provider = BookEditProvider()..setDataStore(dataStore); - await provider.loadBook(storedBook.id); - expect(provider.editedBook, storedBook); expect(provider.error, isNull); }); @@ -26,16 +24,12 @@ void main() { final repository = _DelayedSnapshotBookRepository(); final dataStore = DataStore(bookRepository: repository); final provider = BookEditProvider()..setDataStore(dataStore); - final load = provider.loadBook(storedBook.id); await Future.delayed(Duration.zero); - expect(provider.isLoading, isTrue); expect(provider.error, isNull); - repository.controller.add([storedBook]); await load; - expect(provider.editedBook, storedBook); expect(provider.error, isNull); await dataStore.disposeBookRepository(); @@ -44,18 +38,17 @@ void main() { test('saving a picked cover keeps image bytes out of synced book metadata', () async { final dataStore = DataStore(); + final original = buildTestBook( id: 'book-1', coverUrl: 'https://example.com/original.jpg', ).copyWith(coverMediaId: 'original-cover'); - dataStore.addBook(original); + dataStore.addBook(original); final provider = BookEditProvider()..setDataStore(dataStore); await provider.loadBook(original.id); provider.updateCoverFromFile(Uint8List.fromList([1, 2, 3])); - expect(await provider.save(), isTrue); - final saved = dataStore.getBook(original.id)!; expect(saved.coverUrl, isNull); expect(saved.coverMediaId, 'original-cover'); @@ -64,6 +57,7 @@ void main() { test('saving cleared optional form fields removes their previous values', () async { final dataStore = DataStore(); + final original = Book( id: 'book-1', title: 'Book', @@ -86,8 +80,8 @@ void main() { seriesNumber: 2, addedAt: DateTime.utc(2026), ); - dataStore.addBook(original); + dataStore.addBook(original); final provider = BookEditProvider()..setDataStore(dataStore); await provider.loadBook(original.id); provider.updateSubtitle(''); @@ -105,7 +99,6 @@ void main() { provider.updateRating(null); provider.updateSeriesName(''); provider.updateSeriesNumber(null); - expect(await provider.save(), isTrue); final saved = dataStore.getBook(original.id)!; expect(saved.subtitle, isNull); @@ -133,15 +126,15 @@ void main() { final provider = BookEditProvider()..setDataStore(dataStore); await provider.loadBook(original.id); provider.updateTitle('Updated title'); - var completed = false; + final save = provider.save().then((result) { completed = true; return result; }); + await repository.upsertStarted.future; await Future.delayed(Duration.zero); - expect(completed, isFalse); repository.allowUpsert.complete(); expect(await save, isTrue); diff --git a/app/test/providers/book_storage_status_controller_test.dart b/app/test/providers/book_storage_status_controller_test.dart index de699abe..23d64d48 100644 --- a/app/test/providers/book_storage_status_controller_test.dart +++ b/app/test/providers/book_storage_status_controller_test.dart @@ -23,7 +23,9 @@ void main() { resolveBookAccountStatus(book: _book(fileMediaId: 'media-1'), isAccountLibrary: true), BookAccountStatus.saved, ); + expect(resolveBookAccountStatus(book: _book(), isAccountLibrary: true), BookAccountStatus.syncing); + expect( resolveBookAccountStatus( book: _book(), @@ -50,6 +52,7 @@ void main() { test('local file checks are asynchronous and cached', () async { final checked = Completer(); var calls = 0; + final controller = BookStorageStatusController.detached( hasBookFile: (_) { calls++; @@ -61,12 +64,10 @@ void main() { final firstCheck = controller.ensureDeviceStatus(_book()); final secondCheck = controller.ensureDeviceStatus(_book()); expect(calls, 1); - checked.complete(false); await Future.wait([firstCheck, secondCheck]); expect(controller.deviceStatus(_book()), BookDeviceStatus.missing); expect(calls, 1); - controller.dispose(); }); } diff --git a/app/test/providers/bookmark_persistence_test.dart b/app/test/providers/bookmark_persistence_test.dart index c21d45f1..762af924 100644 --- a/app/test/providers/bookmark_persistence_test.dart +++ b/app/test/providers/bookmark_persistence_test.dart @@ -17,6 +17,7 @@ void main() { late DataStore store; late BookmarksProvider bookmarks; late BookDetailsProvider details; + final original = Bookmark( id: 'bookmark', bookId: 'book', @@ -29,12 +30,14 @@ void main() { setUp(() async { directory = await Directory.systemTemp.createTemp('papyrus-bookmark-edit-'); + service = PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, pathResolver: (mode, profile, user) async => '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', ); + await service.activateGuest(); await service.upsert(buildTestBook(id: 'book')); await service.bookmarks.upsert(original); @@ -75,18 +78,22 @@ void main() { await service.activateAuthenticated('other-account'); await service.upsert(buildTestBook(id: 'book')); await service.bookmarks.upsert(original.copyWith(note: 'Other account note')); + await expectLater( bookmarks.updateBookmarkNote(original.id, 'Stale note', previous: original, repository: repository), throwsStateError, ); + await expectLater( details.updateBookmarkColor(original.id, '#2196F3', previous: original, repository: repository), throwsStateError, ); + await expectLater( details.addBookmark(original.copyWith(id: 'new-bookmark'), repository: repository), throwsStateError, ); + await expectLater(bookmarks.deleteBookmark(original.id, repository: repository), throwsStateError); expect((await service.bookmarks.getById(original.id))!.note, 'Other account note'); expect(await service.bookmarks.getById('new-bookmark'), isNull); diff --git a/app/test/providers/library_favorite_persistence_test.dart b/app/test/providers/library_favorite_persistence_test.dart index bc306554..a8f04bb0 100644 --- a/app/test/providers/library_favorite_persistence_test.dart +++ b/app/test/providers/library_favorite_persistence_test.dart @@ -12,12 +12,14 @@ import '../powersync/powersync_service_test.dart' show OfflineConnector; void main() { test('favorites persist, follow remote changes, and remain scoped', () async { final directory = await Directory.systemTemp.createTemp('papyrus-favorites-'); + PapyrusPowerSyncService open() => PapyrusPowerSyncService( connectorFactory: OfflineConnector.new, connectAuthenticated: false, pathResolver: (mode, profile, user) async => '${directory.path}/${mode == LibraryDatabaseMode.guest ? 'guest' : '$profile-$user'}.db', ); + var service = open(); await service.activateGuest(); await service.upsert(buildTestBook(id: 'book')); diff --git a/app/test/providers/library_provider_test.dart b/app/test/providers/library_provider_test.dart index 35f0b5bd..a1320522 100644 --- a/app/test/providers/library_provider_test.dart +++ b/app/test/providers/library_provider_test.dart @@ -72,7 +72,6 @@ void main() { test('sets each view mode explicitly', () { provider.setViewMode(LibraryViewMode.list); expect(provider.viewMode, LibraryViewMode.list); - provider.setViewMode(LibraryViewMode.grid); expect(provider.viewMode, LibraryViewMode.grid); }); @@ -80,9 +79,7 @@ void main() { test('does not notify when the view mode is unchanged', () { var notifications = 0; provider.addListener(() => notifications++); - provider.setViewMode(LibraryViewMode.grid); - expect(notifications, 0); }); }); @@ -96,7 +93,6 @@ void main() { provider.setFormatFilters({' EPUB '}); provider.setShelfFilters({'shelf-1'}); provider.setTopicFilters({'topic-1'}); - expect(provider.selectedStatuses, {LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); expect(provider.favoriteFilter, FavoriteFilter.favorites); expect(provider.selectedAuthors, {'j.r.r. tolkien'}); @@ -110,6 +106,7 @@ void main() { test('applyFilters replaces the entire filter draft with one notification', () { var notifications = 0; provider.addListener(() => notifications++); + final filters = LibraryFilters( authors: {'frank herbert'}, statuses: {LibraryReadingStatus.completed}, @@ -117,7 +114,6 @@ void main() { ); provider.applyFilters(filters); - expect(provider.filters, filters); expect(provider.activeFilterCount, 3); expect(notifications, 1); @@ -127,9 +123,7 @@ void main() { provider.setSearchQuery('dune'); provider.setStatusFilters({LibraryReadingStatus.completed}); provider.setFavoriteFilter(FavoriteFilter.notFavorites); - provider.clearFilters(); - expect(provider.filters.isEmpty, isTrue); expect(provider.searchQuery, 'dune'); }); @@ -138,9 +132,7 @@ void main() { provider.setStatusFilters({LibraryReadingStatus.inProgress}); provider.setSortOption(LibrarySortOption.titleAZ); provider.setViewMode(LibraryViewMode.list); - provider.resetQuickFilters(); - expect(provider.filters.isEmpty, isTrue); expect(provider.sortOption, LibrarySortOption.dateAddedNewest); expect(provider.viewMode, LibraryViewMode.grid); @@ -151,9 +143,7 @@ void main() { test('sets and clears plain text search independently', () { provider.setStatusFilters({LibraryReadingStatus.inProgress}); provider.setSearchQuery('tolkien'); - provider.clearSearch(); - expect(provider.searchQuery, isEmpty); expect(provider.selectedStatuses, {LibraryReadingStatus.inProgress}); }); @@ -162,9 +152,7 @@ void main() { provider.setSearchQuery('test'); var notifications = 0; provider.addListener(() => notifications++); - provider.setSearchQuery('test'); - expect(notifications, 0); }); }); @@ -176,9 +164,7 @@ void main() { provider.setSearchQuery('tolkien'); provider.setStatusFilters({LibraryReadingStatus.inProgress}); provider.setFavoriteFilter(FavoriteFilter.favorites); - final filtered = provider.filterBooks(books, dataStore: dataStore); - expect(filtered.map((book) => book.id), ['book-1']); }); @@ -186,9 +172,7 @@ void main() { final books = createTestBooks(); final dataStore = createTestDataStore(books: books); provider.setStatusFilters({LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); - final filtered = provider.filterBooks(books, dataStore: dataStore); - expect(filtered.map((book) => book.id), ['book-1', 'book-2', 'book-5']); }); @@ -197,9 +181,7 @@ void main() { final dataStore = createTestDataStore(books: books); provider.setStatusFilters({LibraryReadingStatus.unread}); final draft = LibraryFilters(favoriteFilter: FavoriteFilter.favorites); - final preview = provider.filterBooks(books, dataStore: dataStore, filters: draft); - expect(preview.map((book) => book.id), ['book-1', 'book-4']); expect(provider.selectedStatuses, {LibraryReadingStatus.unread}); }); diff --git a/app/test/providers/statistics_provider_test.dart b/app/test/providers/statistics_provider_test.dart index 9d8c710d..e29d8301 100644 --- a/app/test/providers/statistics_provider_test.dart +++ b/app/test/providers/statistics_provider_test.dart @@ -7,10 +7,12 @@ 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, @@ -19,6 +21,7 @@ void main() { readingStatus: LibraryReadingStatus.completed, completedAt: at, ); + final store = DataStore() ..loadData( books: [ @@ -28,7 +31,9 @@ void main() { completed('undone', today), ], ); + addTearDown(store.dispose); + ReadingActivity completion(String id, String bookId) => ReadingActivity( id: id, bookId: bookId, @@ -38,7 +43,9 @@ void main() { createdAt: today, kind: 'completion', ); + final undone = completion('undone-entry', 'undone'); + await store.commitTracking( activities: [ completion('tracked-one', 'tracked'), @@ -47,6 +54,7 @@ void main() { store.reversalFor(undone), ], ); + final provider = StatisticsProvider()..attach(store); addTearDown(provider.dispose); provider.setPeriod(StatsPeriod.allTime); @@ -56,6 +64,6 @@ void main() { 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); + expect(store.readingActivities.where((activity) => activity.bookId.startsWith('legacy')), isEmpty); }); } diff --git a/app/test/providers/sync_settings_provider_test.dart b/app/test/providers/sync_settings_provider_test.dart index 815ea134..9b1ece07 100644 --- a/app/test/providers/sync_settings_provider_test.dart +++ b/app/test/providers/sync_settings_provider_test.dart @@ -24,9 +24,11 @@ void main() { officialConfig: officialConfig(), discoveryFetcher: (serverUrl) async { final settings = discovered?[serverUrl.toString()]; + if (settings == null) { throw SyncSettingsException('Server settings could not be loaded'); } + return settings; }, ); @@ -35,7 +37,6 @@ void main() { test('defaults to the official data sync profile with 1 GB file storage', () async { final prefs = await SharedPreferences.getInstance(); final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); - expect(provider.activeServerId, SyncSettingsProvider.officialServerId); expect(provider.activeServerLabel, 'Official server'); expect(provider.activeProfileKey, 'official'); @@ -47,6 +48,7 @@ void main() { test('adds multiple custom servers from one public URL and switches the active server', () async { final prefs = await SharedPreferences.getInstance(); + final provider = providerWithFetcher( prefs, discovered: { @@ -64,9 +66,7 @@ void main() { final local = await provider.addCustomServer('localhost:8080'); final remote = await provider.addCustomServer('https://reader.example'); provider.selectServer(local.id); - final restored = SyncSettingsProvider(prefs, officialConfig: officialConfig()); - expect(restored.customServers.map((server) => server.url), ['http://localhost:8080', 'https://reader.example']); expect(restored.activeServerId, local.id); expect(restored.activeServerLabel, 'localhost:8080'); @@ -78,6 +78,7 @@ void main() { test('rejects duplicate custom server URLs after normalization', () async { final prefs = await SharedPreferences.getInstance(); + final provider = providerWithFetcher( prefs, discovered: { @@ -98,6 +99,7 @@ void main() { test('removing the active custom server switches back to official', () async { final prefs = await SharedPreferences.getInstance(); + final provider = providerWithFetcher( prefs, discovered: { @@ -109,9 +111,7 @@ void main() { ); final custom = await provider.addCustomServer('localhost:8080'); - provider.removeCustomServer(custom.id); - expect(provider.activeServerId, SyncSettingsProvider.officialServerId); expect(provider.customServers, isEmpty); }); @@ -122,10 +122,9 @@ void main() { 'sync_custom_api_url': 'http://legacy-api.test', 'sync_custom_powersync_url': 'http://legacy-sync.test', }); - final prefs = await SharedPreferences.getInstance(); + final prefs = await SharedPreferences.getInstance(); final provider = SyncSettingsProvider(prefs, officialConfig: officialConfig()); - expect(provider.customServers, hasLength(1)); expect(provider.activeServerId, provider.customServers.single.id); expect(provider.activeServerLabel, 'legacy-api.test'); diff --git a/app/test/reader/reader_book_adapter_test.dart b/app/test/reader/reader_book_adapter_test.dart index cb4d6888..688d010d 100644 --- a/app/test/reader/reader_book_adapter_test.dart +++ b/app/test/reader/reader_book_adapter_test.dart @@ -70,9 +70,7 @@ void main() { final now = DateTime.utc(2026, 7, 27, 12); final book = buildTestBook(fileFormat: BookFormat.pdf, customMetadata: {'source': 'import'}); final locator = PdfReaderLocator(pageIndex: 4, pageOffset: 0.2, totalProgression: 0.35); - final updated = ReaderBookAdapter.applyLocator(book, locator, now: now); - expect(updated.customMetadata?['source'], 'import'); expect(updated.customMetadata?[ReaderBookAdapter.locatorMetadataKey], locator.toJson()); expect(updated.currentPage, 5); @@ -90,11 +88,10 @@ void main() { 'margins': 'large', 'reading_mode': 'scroll', }); + final provider = PreferencesProvider(await SharedPreferences.getInstance()); final colors = ColorScheme.fromSeed(seedColor: Colors.deepPurple, brightness: Brightness.dark); - final preferences = ReaderBookAdapter.preferencesFor(provider, colors); - expect(preferences.fontFamily, 'Georgia'); expect(preferences.fontSize, 20); expect(preferences.lineHeight, 1.75); @@ -108,6 +105,7 @@ void main() { test('persists reader setting changes as application defaults', () async { SharedPreferences.setMockInitialValues({}); final provider = PreferencesProvider(await SharedPreferences.getInstance()); + const readerPreferences = ReaderPreferences( fontFamily: 'Atkinson Hyperlegible', fontSize: 22, @@ -117,7 +115,6 @@ void main() { ); ReaderBookAdapter.persistPreferences(provider, readerPreferences); - expect(provider.defaultFont, 'Atkinson Hyperlegible'); expect(provider.defaultFontSize, 22); expect(provider.lineSpacing, 'relaxed'); diff --git a/app/test/reader/reader_session_test.dart b/app/test/reader/reader_session_test.dart index df1d50ec..59c37d05 100644 --- a/app/test/reader/reader_session_test.dart +++ b/app/test/reader/reader_session_test.dart @@ -8,6 +8,7 @@ import '../helpers/test_helpers.dart'; void main() { test('coalesces rapid locator changes into the latest saved book', () async { final saved = []; + final session = ReaderSession( book: buildTestBook(fileFormat: BookFormat.pdf), saveBook: saved.add, @@ -18,7 +19,6 @@ void main() { session.updateLocator(PdfReaderLocator(pageIndex: 1, pageOffset: 0, totalProgression: 0.1)); session.updateLocator(PdfReaderLocator(pageIndex: 4, pageOffset: 0, totalProgression: 0.4)); await Future.delayed(Duration.zero); - expect(saved, hasLength(1)); expect(saved.single.currentPage, 5); expect(saved.single.currentPosition, 0.4); @@ -27,6 +27,7 @@ void main() { test('dispose flushes the final pending locator', () { final saved = []; + final session = ReaderSession( book: buildTestBook(fileFormat: BookFormat.epub), saveBook: saved.add, @@ -37,8 +38,8 @@ void main() { session.updateLocator( EpubReaderLocator(cfi: 'epubcfi(/6/4)', spineIndex: 1, localProgression: 0.5, totalProgression: 0.25), ); - session.dispose(); + session.dispose(); expect(saved, hasLength(1)); expect(saved.single.currentCfi, 'epubcfi(/6/4)'); expect(saved.single.currentPosition, 0.25); diff --git a/app/test/reader/reading_activity_tracker_test.dart b/app/test/reader/reading_activity_tracker_test.dart index c59eeaea..a59bab80 100644 --- a/app/test/reader/reading_activity_tracker_test.dart +++ b/app/test/reader/reading_activity_tracker_test.dart @@ -10,10 +10,12 @@ 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 [], @@ -25,29 +27,38 @@ class RecordingRepository implements TrackingRepository { String? readerBookId, Map? readerPatch, }) async { - attemptedIds.add(activities.map((a) => a.id).toList()); + attemptedIds.add(activities.map((activity) => activity.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); + + 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), @@ -57,6 +68,7 @@ void main() { now: () => now, checkpointInterval: const Duration(days: 1), ); + tracker.onActivity(event(ready: false)); now = now.add(const Duration(minutes: 2)); await tracker.flush(); @@ -74,14 +86,16 @@ void main() { 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.fold(0, (total, activity) => total + activity.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), @@ -91,21 +105,29 @@ void main() { 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'}); + + expect(repository.records.values.expand((activity) => activity.coverage).map((item) => item.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), @@ -115,6 +137,7 @@ void main() { now: () => now, checkpointInterval: const Duration(days: 1), ); + tracker.onActivity(event()); now = now.add(const Duration(seconds: 10)); await tracker.flush(); @@ -127,10 +150,12 @@ void main() { 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), @@ -140,14 +165,17 @@ void main() { 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/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 473b3028..e53c19b8 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -36,6 +36,7 @@ void main() { tearDown(() async { _clearImageCache(); + if (root.existsSync()) { await root.delete(recursive: true); } @@ -45,21 +46,18 @@ void main() { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); final bytes = Uint8List.fromList([1, 2, 3]); - await service.storeCoverFile(first, 'asset-1', bytes); - expect(await service.getCoverFile(first, 'asset-1'), bytes); expect(await service.getCoverFile(second, 'asset-1'), isNull); }); test('cached and pending covers use separate bucket files', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - await service.storeCoverFile(scope, 'shared-id', Uint8List.fromList([1])); await service.storePendingCoverFile(scope, 'shared-id', Uint8List.fromList([2])); - expect(await service.getCoverFile(scope, 'shared-id'), Uint8List.fromList([1])); expect(await service.getPendingCoverFile(scope, 'shared-id'), Uint8List.fromList([2])); + expect( File( '${root.path}/media-covers/${scope.persistenceKey}/' @@ -67,6 +65,7 @@ void main() { ).existsSync(), isTrue, ); + expect( File( '${root.path}/media-covers/${scope.persistenceKey}/' @@ -78,8 +77,8 @@ void main() { test('guest covers use the local guest books namespace', () async { await service.storeGuestCoverFile('book-1', Uint8List.fromList([3, 4])); - expect(await service.getGuestCoverFile('book-1'), Uint8List.fromList([3, 4])); + expect( File( '${root.path}/media-covers/${MediaStorageScope.localGuest.persistenceKey}/' @@ -96,30 +95,24 @@ void main() { test('promoting pending cover writes cache before removing pending', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([5, 6])); - await service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1'); - expect(await service.getCoverFile(scope, 'asset-1'), Uint8List.fromList([5, 6])); expect(await service.getPendingCoverFile(scope, 'book-1'), isNull); }); test('deleting pending covers is idempotent', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - await service.deletePendingCoverFile(scope, 'book-1'); await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([7])); await service.deletePendingCoverFile(scope, 'book-1'); await service.deletePendingCoverFile(scope, 'book-1'); - expect(await service.getPendingCoverFile(scope, 'book-1'), isNull); }); test('storing a cover atomically replaces existing bytes', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([1, 2, 3])); await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([4, 5])); - expect(await service.getCoverFile(scope, 'asset-1'), Uint8List.fromList([4, 5])); expect(root.listSync(recursive: true).whereType().where((file) => file.path.endsWith('.tmp')), isEmpty); }); @@ -137,6 +130,7 @@ void main() { final stored = await service.getCoverFile(scope, 'asset-1'); expect(candidates.any((bytes) => _bytesEqual(bytes, stored)), isTrue); + expect( root .listSync(recursive: true) @@ -148,6 +142,7 @@ void main() { test('native replacement uses direct rename without moving the existing cache aside', () { final source = File('lib/services/book_import_service_stub.dart').readAsStringSync(); + final writer = source.substring( source.indexOf('Future _writeCoverFile'), source.indexOf('Future _removeCoverFile'), @@ -161,11 +156,9 @@ void main() { test('pending store queued during promotion preserves the newer generation', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([1])); - final promotion = service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1'); final newerStore = service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([2])); await Future.wait([promotion, newerStore]); - expect(await service.getCoverFile(scope, 'asset-1'), Uint8List.fromList([1])); expect(await service.getPendingCoverFile(scope, 'book-1'), Uint8List.fromList([2])); }); @@ -173,10 +166,8 @@ void main() { test('deleting a cover is idempotent', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([1])); - await service.deleteCoverFile(scope, 'asset-1'); await service.deleteCoverFile(scope, 'asset-1'); - expect(await service.getCoverFile(scope, 'asset-1'), isNull); }); @@ -197,6 +188,7 @@ void main() { ]) { testWidgets('deleting a ${deletion.name} cover evicts its decoded image', (tester) async { var loads = 0; + Future loadBytes() async { loads++; return _pngBytes; @@ -204,12 +196,10 @@ void main() { await tester.runAsync(() => _storeCover(service, deletion.scope, deletion.bucket, deletion.id)); await _pumpCover(tester, deletion.scope, deletion.bucket, deletion.id, loadBytes); - await tester.runAsync(() => _deleteCover(service, deletion.scope, deletion.bucket, deletion.id)); await tester.pumpWidget(const SizedBox()); await tester.pump(); await _pumpCover(tester, deletion.scope, deletion.bucket, deletion.id, loadBytes); - expect(loads, 2); }); } @@ -223,6 +213,7 @@ void main() { await service.storePendingCoverFile(scope, 'book-1', _pngBytes); await service.storeCoverFile(scope, 'asset-1', _pngBytes); }); + await tester.pumpWidget( MaterialApp( home: Row( @@ -253,17 +244,20 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); await tester.runAsync(() => service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1')); await tester.pumpWidget(const SizedBox()); await tester.pump(); + await _pumpCover(tester, scope, CoverStorageBucket.pending, 'book-1', () async { pendingLoads++; return _pngBytes; }); + await tester.pumpWidget(const SizedBox()); await tester.pump(); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', () async { cachedLoads++; return _pngBytes; @@ -276,6 +270,7 @@ void main() { testWidgets('ordinary cover stores do not evict a decoded image', (tester) async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); var loads = 0; + Future loadBytes() async { loads++; return _pngBytes; @@ -286,13 +281,13 @@ void main() { await tester.pump(); await tester.runAsync(() => service.storeCoverFile(scope, 'asset-1', _pngBytes)); await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); - expect(loads, 1); }); testWidgets('filesystem deletion failure preserves a decoded image', (tester) async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); var loads = 0; + Future loadBytes() async { loads++; return _pngBytes; @@ -301,26 +296,24 @@ void main() { await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); await tester.pumpWidget(const SizedBox()); await tester.pump(); - final blocker = File('${root.path}/not-a-directory')..writeAsStringSync('blocked'); PathProviderPlatform.instance = _FakePathProvider(Directory(blocker.path)); + await tester.runAsync( () => expectLater(service.deleteCoverFile(scope, 'asset-1'), throwsA(isA())), ); - await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); expect(loads, 1); }); test('clearing covers removes only the selected scope', () async { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); - await service.storeCoverFile(first, 'asset-1', Uint8List.fromList([1])); await service.storePendingCoverFile(first, 'book-1', Uint8List.fromList([3])); await service.storeCoverFile(second, 'asset-2', Uint8List.fromList([2])); await service.clearCoverFiles(first); - expect(await service.getCoverFile(first, 'asset-1'), isNull); expect(await service.getPendingCoverFile(first, 'book-1'), isNull); expect(await service.getCoverFile(second, 'asset-2'), Uint8List.fromList([2])); @@ -328,7 +321,6 @@ void main() { test('cover storage rejects unsafe media ids', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); - expect(() => service.storeCoverFile(scope, '../asset', Uint8List.fromList([1])), throwsArgumentError); expect(() => service.storePendingCoverFile(scope, r'book\1', Uint8List.fromList([1])), throwsArgumentError); expect(() => service.storeGuestCoverFile('../book', Uint8List.fromList([1])), throwsArgumentError); @@ -340,6 +332,7 @@ void main() { for (final action in ['getCover', 'storeCover', 'deleteCover', 'promoteCover', 'clearCovers']) { expect(source, contains("case '$action':")); } + expect(source, contains("new Set(['cached', 'pending', 'books'])")); expect(source, contains("validateCoverBucket(bucket)")); expect(source, contains("getDirectoryHandle(bucket, { create })")); @@ -465,6 +458,7 @@ vm.runInContext(source, context); test('web cover writes transfer a copy so caller bytes remain renderable', () { final source = File('lib/services/book_import_service.dart').readAsStringSync(); + final helper = source.substring( source.indexOf('Future _sendCoverRequest'), source.indexOf('BookImportResult _parseImportResult'), @@ -477,24 +471,29 @@ vm.runInContext(source, context); test('web cover mutations evict only successfully removed decoded keys', () { final source = File('lib/services/book_import_service.dart').readAsStringSync(); + final deleteHelper = source.substring( source.indexOf('Future _deleteCoverFile'), source.indexOf('Future clearCoverFiles'), ); + final promotion = source.substring( source.indexOf('Future promotePendingCoverFile'), source.indexOf('Future _getCoverFile'), ); + final storeHelper = source.substring( source.indexOf('Future _storeCoverFile'), source.indexOf('Future _deleteCoverFile'), ); expect(source, contains("import 'package:papyrus/media/local_cover_image_provider.dart';")); + expect( deleteHelper.indexOf("await _sendCoverRequest(type: 'deleteCover'"), lessThan(deleteHelper.indexOf('LocalCoverImageProvider.evictKey(')), ); + expect(deleteHelper, contains('scopeKey: scope.persistenceKey')); expect(deleteHelper, contains('bucket: bucket')); expect(deleteHelper, contains('fileId: id')); @@ -544,6 +543,7 @@ Future _pumpCover( ), ), ); + await tester.pumpAndSettle(); } @@ -554,9 +554,15 @@ void _clearImageCache() { } bool _bytesEqual(Uint8List first, Uint8List? second) { - if (second == null || first.length != second.length) return false; + if (second == null || first.length != second.length) { + return false; + } + for (var index = 0; index < first.length; index++) { - if (first[index] != second[index]) return false; + if (first[index] != second[index]) { + return false; + } } + return true; } diff --git a/app/test/services/book_delete_cleanup_service_test.dart b/app/test/services/book_delete_cleanup_service_test.dart index 3dc94850..95d3ae0c 100644 --- a/app/test/services/book_delete_cleanup_service_test.dart +++ b/app/test/services/book_delete_cleanup_service_test.dart @@ -18,6 +18,7 @@ void main() { final dataStore = DataStore(bookRepository: repository); final queue = MediaUploadQueue(prefs); await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + final book = Book( id: 'book-1', title: 'Book', @@ -25,9 +26,9 @@ void main() { coverMediaId: 'cover-1', addedAt: DateTime.utc(2026), ); + final deletedLocalFiles = []; final deletedCovers = []; - await repository.upsert(book); await pumpEventQueue(); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); @@ -42,8 +43,8 @@ void main() { deleteGuestCover: (bookId) async => deletedCovers.add('guest:$bookId'), deleteCoverFile: (mediaId) async => deletedCovers.add('cached:$mediaId'), ); - await pumpEventQueue(); + await pumpEventQueue(); expect(queue.pendingTasks, isEmpty); expect(deletedLocalFiles, [book.id]); expect(deletedCovers, ['pending:${book.id}', 'guest:${book.id}', 'cached:cover-1']); diff --git a/app/test/services/book_import_commit_service_test.dart b/app/test/services/book_import_commit_service_test.dart index 13bf5229..b53c993c 100644 --- a/app/test/services/book_import_commit_service_test.dart +++ b/app/test/services/book_import_commit_service_test.dart @@ -23,6 +23,7 @@ void main() { test('account import persists cover before metadata and enqueues metadata-only uploads in order', () async { final calls = []; Book? addedBook; + final service = BookImportCommitService( storePendingCover: (actualScope, bookId, bytes) async { expect(actualScope, accountScope); @@ -91,6 +92,7 @@ void main() { test('guest import stores permanent cover before adding and never enqueues media', () async { final calls = []; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('pending cover must not be stored for a guest'), storeGuestCover: (bookId, bytes) async { @@ -129,6 +131,7 @@ void main() { test('no-cover import skips cover work and follows account book-file ordering', () async { final calls = []; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), storeGuestCover: (_, _) async => fail('cover storage must be skipped'), @@ -167,6 +170,7 @@ void main() { final failure = StateError('disk full'); var added = false; var enqueued = false; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => throw failure, storeGuestCover: (_, _) async => throw failure, @@ -197,6 +201,7 @@ void main() { ), throwsA(same(failure)), ); + expect(added, isFalse); expect(enqueued, isFalse); }); @@ -204,6 +209,7 @@ void main() { test('account add failure deletes the stored pending cover and preserves the original error', () async { final failure = StateError('add failed'); final calls = []; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => calls.add('store pending cover'), storeGuestCover: (_, _) async => fail('guest cover must not be stored'), @@ -240,12 +246,14 @@ void main() { ), throwsA(same(failure)), ); + expect(calls, ['store pending cover', 'add book', 'delete pending cover']); }); test('guest add failure deletes the stored guest cover', () async { final failure = StateError('add failed'); final calls = []; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('pending cover must not be stored'), storeGuestCover: (_, _) async => calls.add('store guest cover'), @@ -278,6 +286,7 @@ void main() { ), throwsA(same(failure)), ); + expect(calls, ['store guest cover', 'add book', 'delete guest cover']); }); @@ -288,6 +297,7 @@ void main() { final queue = MediaUploadQueue(prefs); await queue.activateScope(accountScope); await queue.activateScope(switchedScope); + final service = BookImportCommitService( storePendingCover: (_, _, _) async => calls.add('store pending cover'), storeGuestCover: (_, _) async => fail('guest cover must not be stored'), @@ -305,6 +315,7 @@ void main() { coverContentType, }) async { calls.add('enqueue imported media'); + await queue.enqueueImportedBookMedia( scope: scope, book: book, @@ -326,6 +337,7 @@ void main() { ), throwsStateError, ); + expect(calls, ['store pending cover', 'add book', 'enqueue imported media', 'delete book', 'delete pending cover']); expect(queue.pendingTasks, isEmpty); expect(prefs.getString('media_upload_queue:${accountScope.persistenceKey}'), isNull); @@ -336,6 +348,7 @@ void main() { final failure = StateError('enqueue failed'); final repository = _GatedDeleteBookRepository(); final dataStore = DataStore(bookRepository: repository); + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), storeGuestCover: (_, _) async => fail('cover storage must be skipped'), @@ -358,6 +371,7 @@ void main() { ); var completed = false; + final commit = service.commit( result: _result(), sourceFilename: 'original.epub', @@ -365,16 +379,14 @@ void main() { localFilePath: 'book-1', accountScope: accountScope, ); - commit.then((_) => completed = true, onError: (_) => completed = true); + commit.then((_) => completed = true, onError: (_) => completed = true); await repository.deleteStarted.future; expect(completed, isFalse); expect(await repository.getById('book-1'), isNotNull); - repository.allowDelete.complete(); await expectLater(commit, throwsA(same(failure))); expect(await repository.getById('book-1'), isNull); - await dataStore.disposeBookRepository(); await repository.dispose(); }); @@ -383,6 +395,7 @@ void main() { final repository = _GatedDeleteBookRepository(gateUpsert: true); final dataStore = DataStore(bookRepository: repository); var enqueueStarted = false; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), storeGuestCover: (_, _) async => fail('cover storage must be skipped'), @@ -410,13 +423,12 @@ void main() { localFilePath: 'book-1', accountScope: accountScope, ); + await repository.upsertStarted!.future; expect(enqueueStarted, isFalse); - repository.allowUpsert!.complete(); await commit; expect(enqueueStarted, isTrue); - await dataStore.disposeBookRepository(); await repository.dispose(); }); @@ -429,6 +441,7 @@ void main() { final coverStarted = Completer(); final allowCover = Completer(); var guestCoverDeleted = false; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('pending cover must not be stored'), storeGuestCover: (_, _) async { @@ -457,15 +470,14 @@ void main() { addedAt: addedAt, localFilePath: 'book-1', ); + await coverStarted.future; await dataStore.attachBookRepository(second); allowCover.complete(); - await expectLater(commit, throwsStateError); expect(await first.getById('book-1'), isNull); expect(await second.getById('book-1'), isNull); expect(guestCoverDeleted, isTrue); - await dataStore.disposeBookRepository(); await first.dispose(); await second.dispose(); @@ -483,6 +495,7 @@ void main() { final coverStarted = Completer(); final allowCover = Completer(); MediaStorageScope? deletedCoverScope; + final service = BookImportCommitService( storePendingCover: (_, _, _) async { coverStarted.complete(); @@ -504,17 +517,16 @@ void main() { localFilePath: 'book-1', accountScope: accountScope, ); + await coverStarted.future; await dataStore.attachBookRepository(second); await queue.activateScope(secondScope); allowCover.complete(); - await expectLater(commit, throwsStateError); expect(await first.getById('book-1'), isNull); expect(await second.getById('book-1'), isNull); expect(queue.pendingTasks, isEmpty); expect(deletedCoverScope, accountScope); - await dataStore.disposeBookRepository(); await first.dispose(); await second.dispose(); @@ -527,6 +539,7 @@ void main() { final dataStore = DataStore(bookRepository: first); final captured = dataStore.requireBookRepository(); var enqueued = false; + final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), storeGuestCover: (_, _) async => fail('cover storage must be skipped'), @@ -565,7 +578,6 @@ void main() { expect(await first.getById('book-1'), isNull); expect(await second.getById('book-1'), isNull); expect(enqueued, isFalse); - await dataStore.disposeBookRepository(); await first.dispose(); await second.dispose(); diff --git a/app/test/services/book_import_service_test.dart b/app/test/services/book_import_service_test.dart index ebd49051..8754c119 100644 --- a/app/test/services/book_import_service_test.dart +++ b/app/test/services/book_import_service_test.dart @@ -19,16 +19,17 @@ class _FakePathProvider extends Fake with MockPlatformInterfaceMixin implements void main() { final uuidMatcher = matches(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$')); - late BookImportService service; late Directory tempDir; Uint8List? loadTestFile(String name) { final file = File('test/data/$name'); + if (!file.existsSync()) { markTestSkipped('Test book file $name not available'); return null; } + return file.readAsBytesSync(); } @@ -40,6 +41,7 @@ void main() { tearDown(() async { service.dispose(); + if (tempDir.existsSync()) { await tempDir.delete(recursive: true); } @@ -49,10 +51,12 @@ void main() { group('importBook', () { test('imports book1.epub with metadata', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; - final result = await service.importBook(bytes, 'book1.epub'); + if (bytes == null) { + return; + } + final result = await service.importBook(bytes, 'book1.epub'); expect(result.bookId, uuidMatcher); expect(result.title, isNotEmpty); expect(result.fileSize, bytes.length); @@ -63,10 +67,12 @@ void main() { test('imports book2.epub with metadata', () async { final bytes = loadTestFile('book2.epub'); - if (bytes == null) return; - final result = await service.importBook(bytes, 'book2.epub'); + if (bytes == null) { + return; + } + final result = await service.importBook(bytes, 'book2.epub'); expect(result.bookId, uuidMatcher); expect(result.title, isNotEmpty); expect(result.fileSize, bytes.length); @@ -76,10 +82,12 @@ void main() { test('imports book3.epub with metadata', () async { final bytes = loadTestFile('book3.epub'); - if (bytes == null) return; - final result = await service.importBook(bytes, 'book3.epub'); + if (bytes == null) { + return; + } + final result = await service.importBook(bytes, 'book3.epub'); expect(result.bookId, uuidMatcher); expect(result.title, isNotEmpty); expect(result.fileSize, bytes.length); @@ -89,41 +97,49 @@ void main() { test('generates unique book IDs', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; + + if (bytes == null) { + return; + } final result1 = await service.importBook(bytes, 'book1.epub'); final result2 = await service.importBook(bytes, 'book1.epub'); - expect(result1.bookId, isNot(result2.bookId)); }); test('same file produces same hash', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; + + if (bytes == null) { + return; + } final result1 = await service.importBook(bytes, 'book1.epub'); final result2 = await service.importBook(bytes, 'book1.epub'); - expect(result1.fileHash, result2.fileHash); }); test('different files produce different hashes', () async { final bytes1 = loadTestFile('book1.epub'); final bytes2 = loadTestFile('book2.epub'); - if (bytes1 == null || bytes2 == null) return; + + if (bytes1 == null || bytes2 == null) { + return; + } final result1 = await service.importBook(bytes1, 'book1.epub'); final result2 = await service.importBook(bytes2, 'book2.epub'); - expect(result1.fileHash, isNot(result2.fileHash)); }); test('stores file to disk', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; - final result = await service.importBook(bytes, 'book1.epub'); + if (bytes == null) { + return; + } + final result = await service.importBook(bytes, 'book1.epub'); final storedFile = File(p.join(tempDir.path, 'books', '${result.bookId}.epub')); expect(storedFile.existsSync(), isTrue); expect(storedFile.lengthSync(), bytes.length); @@ -131,13 +147,11 @@ void main() { test('throws ArgumentError for files without extension', () async { final bytes = Uint8List(10); - expect(() => service.importBook(bytes, 'noext'), throwsArgumentError); }); test('handles corrupted epub gracefully', () async { final bytes = Uint8List.fromList([0, 1, 2, 3, 4, 5]); - final result = await service.importBook(bytes, 'bad.epub'); // Should still return a result (FileMetadataService never throws) @@ -149,9 +163,7 @@ void main() { test('imports txt file with author-title pattern', () async { final bytes = Uint8List.fromList('Hello, this is a test book with some content.'.codeUnits); - final result = await service.importBook(bytes, 'Jane Austen - Pride and Prejudice.txt'); - expect(result.title, 'Pride and Prejudice'); expect(result.author, 'Jane Austen'); expect(result.fileExtension, 'txt'); @@ -161,18 +173,19 @@ void main() { group('getBookFile', () { test('retrieves stored file bytes', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; + + if (bytes == null) { + return; + } final result = await service.importBook(bytes, 'book1.epub'); final retrieved = await service.getBookFile(result.bookId); - expect(retrieved, isNotNull); expect(retrieved!.length, bytes.length); }); test('returns null for non-existent book', () async { final result = await service.getBookFile('non-existent-id'); - expect(result, isNull); }); }); @@ -180,9 +193,7 @@ void main() { group('hasBookFile', () { test('checks file presence without loading file bytes', () async { expect(await service.hasBookFile('cached-book'), isFalse); - await service.storeBookFile('cached-book', 'epub', Uint8List.fromList([1, 2, 3])); - expect(await service.hasBookFile('cached-book'), isTrue); }); }); @@ -190,9 +201,7 @@ void main() { group('storeBookFile', () { test('stores downloaded book bytes with the provided extension', () async { final bytes = Uint8List.fromList('downloaded epub bytes'.codeUnits); - await service.storeBookFile('downloaded-book', 'epub', bytes); - final retrieved = await service.getBookFile('downloaded-book'); final storedFile = File(p.join(tempDir.path, 'books', 'downloaded-book.epub')); expect(storedFile.existsSync(), isTrue); @@ -202,7 +211,6 @@ void main() { test('normalizes extension and replaces existing cached file', () async { await service.storeBookFile('downloaded-book', '.epub', Uint8List.fromList([1, 2, 3])); await service.storeBookFile('downloaded-book', 'epub', Uint8List.fromList([4, 5])); - final retrieved = await service.getBookFile('downloaded-book'); expect(retrieved, Uint8List.fromList([4, 5])); }); @@ -211,7 +219,10 @@ void main() { group('deleteBookFile', () { test('removes stored file', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; + + if (bytes == null) { + return; + } final result = await service.importBook(bytes, 'book1.epub'); @@ -236,7 +247,10 @@ void main() { group('round-trip', () { test('import → get → delete → get returns null', () async { final bytes = loadTestFile('book1.epub'); - if (bytes == null) return; + + if (bytes == null) { + return; + } final result = await service.importBook(bytes, 'book1.epub'); diff --git a/app/test/services/book_import_session_test.dart b/app/test/services/book_import_session_test.dart index 234c97e2..5a969f36 100644 --- a/app/test/services/book_import_session_test.dart +++ b/app/test/services/book_import_session_test.dart @@ -14,8 +14,10 @@ class _Repository extends Fake implements BookRepository {} class _DataStore extends Fake implements DataStore { final repository = _Repository(); + @override BookRepository requireBookRepository() => repository; + @override bool isBookRepositoryCurrent(BookRepository repository) => identical(this.repository, repository); } @@ -23,8 +25,10 @@ class _DataStore extends Fake implements DataStore { class _Auth extends Fake implements AuthProvider { @override bool get isSignedIn => true; + @override bool get isOfflineMode => false; + @override PapyrusUser get user => PapyrusUser.fromJson({'user_id': 'bob'}); } @@ -36,6 +40,7 @@ class _Queue extends Fake implements MediaUploadQueue { class _Database extends Fake implements PapyrusPowerSyncService { _Database(this.mode); + @override final LibraryDatabaseMode mode; } @@ -55,6 +60,7 @@ void main() { throwsStateError, ); }); + test('cannot capture a guest database while an authenticated account is activating', () { expect( () => BookImportSession.capture( diff --git a/app/test/services/file_metadata_service_test.dart b/app/test/services/file_metadata_service_test.dart index 4c618180..45a83587 100644 --- a/app/test/services/file_metadata_service_test.dart +++ b/app/test/services/file_metadata_service_test.dart @@ -14,10 +14,12 @@ void main() { Uint8List? loadTestFile(String name) { final file = File('test/data/files/$name'); + if (!file.existsSync()) { markTestSkipped('Test book file $name not available'); return null; } + return file.readAsBytesSync(); } @@ -25,9 +27,12 @@ void main() { group('MOBI extraction', () { test('parses 1.mobi metadata (KF8, title, author, language)', () async { final bytes = loadTestFile('1.mobi'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '1.mobi'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '1.mobi'); expect(result.title, 'Im Kampf um Ideale'); expect(result.authors, contains('Georg Bonne')); expect(result.language, 'de'); @@ -35,7 +40,11 @@ void main() { test('parses 2.mobi without crash (MOBI v6, limited EXTH support)', () async { final bytes = loadTestFile('2.mobi'); - if (bytes == null) return; + + if (bytes == null) { + return; + } + final result = await service.extractMetadata(bytes, '2.mobi'); // MOBI v6 has limited EXTH support in dart_mobi — metadata @@ -47,9 +56,12 @@ void main() { group('EPUB extraction', () { test('parses 3.epub metadata (EPUB 2, title, author, cover, date)', () async { final bytes = loadTestFile('3.epub'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '3.epub'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '3.epub'); expect(result.title, 'Im Kampf um Ideale'); expect(result.authors, contains('Georg Bonne')); expect(result.language, 'de'); @@ -60,9 +72,12 @@ void main() { test('parses 4.epub metadata (EPUB 3, title, author, cover, date)', () async { final bytes = loadTestFile('4.epub'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '4.epub'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '4.epub'); expect(result.title, 'Im Kampf um Ideale'); expect(result.authors, contains('Georg Bonne')); expect(result.language, 'de'); @@ -73,9 +88,12 @@ void main() { test('parses 5.epub metadata (EPUB 2, title, author, cover, date)', () async { final bytes = loadTestFile('5.epub'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '5.epub'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '5.epub'); expect(result.title, 'Im Kampf um Ideale'); expect(result.authors, contains('Georg Bonne')); expect(result.language, 'de'); @@ -88,9 +106,12 @@ void main() { group('AZW3 extraction', () { test('parses 6.azw3 metadata (title, author, publisher, description, language)', () async { final bytes = loadTestFile('6.azw3'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '6.azw3'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '6.azw3'); expect(result.title, 'Dracula'); expect(result.authors, contains('Bram Stoker')); expect(result.publisher, 'Standard Ebooks'); @@ -102,9 +123,12 @@ void main() { group('CBZ extraction', () { test('parses 7.cbz (no ComicInfo.xml, cover from first image, warning)', () async { final bytes = loadTestFile('7.cbz'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '7.cbz'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '7.cbz'); expect(result.coverImageBytes, isNotNull); expect(result.warnings, contains('No ComicInfo.xml found in CBZ archive')); }); @@ -113,9 +137,12 @@ void main() { group('CBR extraction', () { test('handles 8.cbr RAR v4 gracefully (returns warning, no crash)', () async { final bytes = loadTestFile('8.cbr'); - if (bytes == null) return; - final result = await service.extractMetadata(bytes, '8.cbr'); + if (bytes == null) { + return; + } + + final result = await service.extractMetadata(bytes, '8.cbr'); expect(result.warnings, isNotEmpty); }); }); @@ -124,7 +151,6 @@ void main() { test('parses "Author - Title" filename pattern', () async { final bytes = Uint8List.fromList(utf8.encode('Some book content.')); final result = await service.extractMetadata(bytes, 'Author Name - Book Title.txt'); - expect(result.title, 'Book Title'); expect(result.authors, ['Author Name']); }); @@ -132,7 +158,6 @@ void main() { test('preserves multiple hyphens in title', () async { final bytes = Uint8List.fromList(utf8.encode('content')); final result = await service.extractMetadata(bytes, 'Author - Part 1 - The Beginning.txt'); - expect(result.title, 'Part 1 - The Beginning'); expect(result.authors, ['Author']); }); @@ -140,7 +165,6 @@ void main() { test('uses filename as title when no separator found', () async { final bytes = Uint8List.fromList(utf8.encode('Some book content.')); final result = await service.extractMetadata(bytes, 'JustATitle.txt'); - expect(result.title, 'JustATitle'); expect(result.warnings, contains('Could not detect author from filename')); }); @@ -149,7 +173,6 @@ void main() { // 3000 bytes should yield 2 pages at ~1500 chars/page final bytes = Uint8List(3000); final result = await service.extractMetadata(bytes, 'book.txt'); - expect(result.pageCount, 2); }); }); @@ -180,7 +203,6 @@ void main() { test('returns warning for unsupported file extension', () async { final bytes = Uint8List(0); final result = await service.extractMetadata(bytes, 'file.xyz'); - expect(result.warnings, contains('Unsupported file format: .xyz')); }); }); diff --git a/app/test/themes/eink_input_focus_test.dart b/app/test/themes/eink_input_focus_test.dart index 18073db8..00083685 100644 --- a/app/test/themes/eink_input_focus_test.dart +++ b/app/test/themes/eink_input_focus_test.dart @@ -8,6 +8,7 @@ import 'package:papyrus/themes/app_theme.dart'; Future _pixels(WidgetTester tester, GlobalKey key) async { final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; + return (await tester.runAsync(() async { final image = await boundary.toImage(); final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); @@ -24,6 +25,7 @@ void main() { final controller = TextEditingController(); addTearDown(focus.dispose); addTearDown(controller.dispose); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -49,6 +51,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final initialSize = tester.getSize(find.byType(TextField)); final unfocused = await _pixels(tester, key); @@ -59,11 +62,13 @@ void main() { final focused = await _pixels(tester, key); expect(listEquals(unfocused, focused), isFalse, reason: 'Keyboard focus must remain visibly distinct'); await tester.pump(const Duration(milliseconds: 80)); + expect( listEquals(focused, await _pixels(tester, key)), isTrue, reason: 'Focus must not interpolate the border or label', ); + await tester.pump(const Duration(milliseconds: 200)); expect(listEquals(focused, await _pixels(tester, key)), isTrue); expect(tester.getSize(find.byType(TextField)), initialSize); @@ -87,6 +92,7 @@ void main() { final key = GlobalKey(); final focus = FocusNode(); addTearDown(focus.dispose); + await tester.pumpWidget( MaterialApp( theme: dark ? AppTheme.dark : AppTheme.light, @@ -107,6 +113,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); focus.requestFocus(); await tester.pump(); @@ -127,6 +134,7 @@ void main() { addTearDown(eink.dispose); addTearDown(focus.dispose); addTearDown(controller.dispose); + await tester.pumpWidget( ValueListenableBuilder( valueListenable: eink, @@ -147,6 +155,7 @@ void main() { ), ), ); + focus.requestFocus(); await tester.pump(); controller.selection = const TextSelection(baseOffset: 1, extentOffset: 4); diff --git a/app/test/themes/eink_theme_test.dart b/app/test/themes/eink_theme_test.dart index 2f72c037..e4b6e020 100644 --- a/app/test/themes/eink_theme_test.dart +++ b/app/test/themes/eink_theme_test.dart @@ -15,6 +15,7 @@ void main() { expect(ink.colorScheme.outline, Colors.black); expect(ink.splashFactory, NoSplash.splashFactory); expect(ink.filledButtonTheme.style?.animationDuration, Duration.zero); + expect( ink.pageTransitionsTheme.builders.values.every((builder) => builder.transitionDuration == Duration.zero), isTrue, @@ -25,6 +26,7 @@ void main() { final disabled = ValueNotifier(false); addTearDown(disabled.dispose); final readings = <(bool, Size, TextScaler)>[]; + await tester.pumpWidget( MaterialApp( theme: AppTheme.light, @@ -45,6 +47,7 @@ void main() { MediaQuery.sizeOf(context), MediaQuery.textScalerOf(context), )); + return const SizedBox(); }, ), @@ -53,6 +56,7 @@ void main() { ), ), ); + expect(readings.last, (true, const Size(1200, 800), const TextScaler.linear(1.3))); disabled.value = true; await tester.pump(); @@ -62,6 +66,7 @@ void main() { testWidgets('e-ink loading indicators stay static and still report actual progress', (tester) async { final progress = ValueNotifier(null); addTearDown(progress.dispose); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -78,6 +83,7 @@ void main() { ), ), ); + await tester.pump(); final circle = tester.widget(find.byType(CircularProgressIndicator)); final line = tester.widget(find.byType(LinearProgressIndicator)); @@ -102,6 +108,7 @@ void main() { home: const Scaffold(body: AppCircularProgressIndicator()), ), ); + await tester.pump(const Duration(milliseconds: 50)); expect(tester.binding.hasScheduledFrame, isTrue); await tester.pumpWidget(const SizedBox()); diff --git a/app/test/widgets/acquisition/acquisition_action_sheets_test.dart b/app/test/widgets/acquisition/acquisition_action_sheets_test.dart index 29328ffe..ebf99270 100644 --- a/app/test/widgets/acquisition/acquisition_action_sheets_test.dart +++ b/app/test/widgets/acquisition/acquisition_action_sheets_test.dart @@ -5,10 +5,8 @@ import 'package:papyrus/widgets/acquisition/acquisition_action_sheets.dart'; void main() { testWidgets('command selection uses a sheet and returns the raw command', (tester) async { await tester.pumpWidget(const _ActionSheetLauncher()); - await tester.tap(find.text('Open commands')); await tester.pumpAndSettle(); - final sheet = find.byKey(const Key('acquisition-command-sheet')); expect(sheet, findsOneWidget); expect(find.byType(Dialog), findsNothing); @@ -17,22 +15,18 @@ void main() { expect(find.text('Search monitored books'), findsOneWidget); expect(find.text('search'), findsOneWidget); expect(find.byIcon(Icons.play_arrow), findsNWidgets(2)); - await tester.tap(find.text('Search monitored books')); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-command-sheet')), findsNothing); expect(find.text('command: search'), findsOneWidget); }); testWidgets('command cancellation returns null', (tester) async { await tester.pumpWidget(const _ActionSheetLauncher()); - await tester.tap(find.text('Open commands')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); await tester.pumpAndSettle(); - expect(find.text('command: null'), findsOneWidget); }); @@ -40,10 +34,8 @@ void main() { tester.view.viewInsets = const FakeViewPadding(bottom: 180); addTearDown(tester.view.resetViewInsets); await tester.pumpWidget(const _ActionSheetLauncher()); - await tester.tap(find.text('Open IDs')); await tester.pumpAndSettle(); - final sheet = find.byKey(const Key('acquisition-arr-ids-sheet')); final keyboardPadding = find.descendant(of: sheet, matching: find.byType(Padding)).first; final keyboardInset = tester.view.viewInsets.bottom / tester.view.devicePixelRatio; @@ -56,59 +48,48 @@ void main() { expect(find.widgetWithText(FilledButton, 'Run'), findsOneWidget); expect(find.text('IDs'), findsOneWidget); expect(find.text('Comma-separated IDs from the Arr application'), findsOneWidget); - await tester.enterText(find.byType(TextField), '42, invalid, 84'); await tester.ensureVisible(find.widgetWithText(FilledButton, 'Run')); expect(find.widgetWithText(FilledButton, 'Run').hitTestable(), findsOneWidget); await tester.tap(find.widgetWithText(FilledButton, 'Run')); await tester.pumpAndSettle(); - expect(find.text('ids: [42, 84]'), findsOneWidget); }); testWidgets('Arr ID cancellation returns null', (tester) async { await tester.pumpWidget(const _ActionSheetLauncher()); - await tester.tap(find.text('Open IDs')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); await tester.pumpAndSettle(); - expect(find.text('ids: null'), findsOneWidget); }); testWidgets('removal uses the destructive shelf-style confirmation dialog and returns true', (tester) async { await tester.pumpWidget(const _ActionSheetLauncher()); - await tester.tap(find.text('Open removal')); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(BottomSheet), findsNothing); expect(find.text('Remove integration'), findsOneWidget); expect(find.text('Remove "Readarr"? Saved credentials for this integration will be removed.'), findsOneWidget); expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); - final removeFinder = find.widgetWithText(FilledButton, 'Remove'); expect(removeFinder, findsOneWidget); final button = tester.widget(removeFinder); final colorScheme = Theme.of(tester.element(removeFinder)).colorScheme; expect(button.style?.backgroundColor?.resolve({}), colorScheme.error); - await tester.tap(removeFinder); await tester.pumpAndSettle(); - expect(find.text('remove: true'), findsOneWidget); }); testWidgets('removal cancellation returns false', (tester) async { await tester.pumpWidget(const _ActionSheetLauncher()); - await tester.tap(find.text('Open removal')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, 'Cancel')); await tester.pumpAndSettle(); - expect(find.text('remove: false'), findsOneWidget); }); } diff --git a/app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart b/app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart index c0e7dbb8..7a1c29a1 100644 --- a/app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart +++ b/app/test/widgets/acquisition/acquisition_endpoint_editor_test.dart @@ -13,13 +13,12 @@ void main() { testWidgets('uses a content-driven draggable bottom sheet on narrow windows', (tester) async { await _setWindowSize(tester, const Size(390, 844)); await tester.pumpWidget(_EditorLauncher()); - await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - final sheet = find.byKey(const Key('acquisition-endpoint-sheet')); expect(sheet, findsOneWidget); expect(find.byKey(const Key('acquisition-endpoint-dialog')), findsNothing); + expect( find.descendant( of: sheet, @@ -27,6 +26,7 @@ void main() { ), findsNothing, ); + expect(find.ancestor(of: sheet, matching: find.byType(AnimatedPadding)), findsOneWidget); final bottomSheet = tester.widget(find.byType(BottomSheet)); expect(bottomSheet.enableDrag, isTrue); @@ -43,13 +43,12 @@ void main() { testWidgets('short editor hugs its content on wide windows', (tester) async { await _setWindowSize(tester, const Size(900, 900)); await tester.pumpWidget(_EditorLauncher()); - await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - final sheet = find.byKey(const Key('acquisition-endpoint-sheet')); expect(sheet, findsOneWidget); expect(find.byKey(const Key('acquisition-endpoint-dialog')), findsNothing); + expect( find.descendant( of: sheet, @@ -57,6 +56,7 @@ void main() { ), findsNothing, ); + expect(tester.getSize(sheet).height, lessThan(700)); expect(find.ancestor(of: sheet, matching: find.byType(SafeArea)), findsOneWidget); }); @@ -78,25 +78,25 @@ void main() { tester.view.viewInsets = const FakeViewPadding(bottom: 300); addTearDown(() => tester.view.viewInsets = FakeViewPadding.zero); await tester.pumpWidget(_EditorLauncher(endpoint: _downloadClient)); - await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - final keyboardInset = tester.view.viewInsets.bottom; final availableHeight = 844 - keyboardInset; final bottomSheet = find.byType(BottomSheet); expect(tester.getSize(bottomSheet).height, lessThanOrEqualTo(keyboardInset + availableHeight * .92)); + expect( tester.getBottomLeft(find.byKey(const Key('acquisition-editor-header'))).dy, lessThanOrEqualTo(availableHeight), ); + expect(find.byType(SingleChildScrollView), findsOneWidget); final verticalScrollable = find .byWidgetPredicate((widget) => widget is Scrollable && widget.axisDirection == AxisDirection.down) .first; - await tester.scrollUntilVisible(find.byKey(const Key('acquisition-password')), 200, scrollable: verticalScrollable); + await tester.scrollUntilVisible(find.byKey(const Key('acquisition-password')), 200, scrollable: verticalScrollable); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); expect(find.byKey(const Key('acquisition-editor-header')), findsOneWidget); }); @@ -106,7 +106,6 @@ void main() { await tester.pumpWidget(_EditorLauncher()); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.text('Integration'), findsOneWidget); expect(find.text('Connection'), findsOneWidget); expect(find.byKey(const Key('acquisition-name')), findsOneWidget); @@ -115,26 +114,22 @@ void main() { expect(find.byKey(const Key('acquisition-api-key')), findsOneWidget); expect(find.byKey(const Key('acquisition-username')), findsNothing); expect(find.byKey(const Key('acquisition-password')), findsNothing); + expect( find.byWidgetPredicate((widget) => widget is SizedBox && widget.height == Spacing.formFieldSpacing), findsAtLeastNWidgets(3), ); await _chooseKind(tester, 'qBittorrent'); - expect(find.byKey(const Key('acquisition-api-key')), findsNothing); expect(find.byKey(const Key('acquisition-username')), findsOneWidget); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); expect(find.byKey(const Key('acquisition-download-root')), findsOneWidget); - await _chooseKind(tester, 'Transmission'); - expect(find.byKey(const Key('acquisition-username')), findsOneWidget); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); expect(find.byKey(const Key('acquisition-download-root')), findsNothing); - await _chooseKind(tester, 'Deluge'); - expect(find.byKey(const Key('acquisition-api-key')), findsNothing); expect(find.byKey(const Key('acquisition-username')), findsNothing); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); @@ -145,15 +140,12 @@ void main() { await tester.pumpWidget(_EditorLauncher()); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.widgetWithText(SwitchListTile, 'Enabled'), findsNothing); - await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); await tester.pumpWidget(_EditorLauncher(endpoint: _endpoint)); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.widgetWithText(SwitchListTile, 'Enabled'), findsOneWidget); }); @@ -162,19 +154,16 @@ void main() { await tester.pumpWidget(_EditorLauncher()); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); - expect(find.text('Enter a name'), findsOneWidget); expect(find.text('Enter a valid server URL'), findsOneWidget); - await tester.enterText(find.byKey(const Key('acquisition-name')), 'Prowlarr'); + for (final invalidUrl in ['prowlarr.local', 'https://', 'https://reader:secret@prowlarr.local']) { await tester.enterText(find.byKey(const Key('acquisition-url')), invalidUrl); await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); - expect(find.text('Enter a valid server URL'), findsOneWidget); } }); @@ -183,6 +172,7 @@ void main() { await _setWindowSize(tester, const Size(900, 900)); var saveCalls = 0; String? savedDownloadRoot; + await tester.pumpWidget( _EditorLauncher( onSave: @@ -201,22 +191,19 @@ void main() { }, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await _chooseKind(tester, 'qBittorrent'); await tester.enterText(find.byKey(const Key('acquisition-name')), 'Downloads'); await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://qbittorrent.local'); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); - expect(find.text('Enter the download root'), findsOneWidget); expect(saveCalls, 0); - await tester.enterText(find.byKey(const Key('acquisition-download-root')), ' /books '); await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(saveCalls, 1); expect(savedDownloadRoot, '/books'); }); @@ -247,36 +234,39 @@ void main() { .obscureText, isFalse, ); + expect(find.byTooltip('Hide API key'), findsOneWidget); }); testWidgets('keeps connection testing in the body and reports success', (tester) async { await _setWindowSize(tester, const Size(900, 900)); final completer = Completer(); + await tester.pumpWidget( _EditorLauncher(onTest: ({required kind, required baseUrl, apiKey, username, password}) => completer.future), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await _enterRequiredFields(tester); - final testButton = find.byKey(const Key('acquisition-test-connection')); final header = find.byKey(const Key('acquisition-editor-header')); expect(find.descendant(of: header, matching: testButton), findsNothing); - await tester.tap(testButton); await tester.pump(); - expect(tester.widget(testButton).onPressed, isNull); expect(tester.widget(find.byKey(const Key('acquisition-save'))).onPressed, isNull); expect(tester.widget(find.byKey(const Key('acquisition-name'))).enabled, isFalse); + expect( tester .widget>(find.byKey(const Key('acquisition-type'))) .onChanged, isNull, ); + expect(tester.widget(find.widgetWithText(OutlinedButton, 'Cancel')).onPressed, isNull); + expect( tester .widget( @@ -288,13 +278,13 @@ void main() { completer.complete(); await tester.pumpAndSettle(); - expect(find.text('Connection successful.'), findsOneWidget); }); testWidgets('tests valid connection details without requiring a name', (tester) async { await _setWindowSize(tester, const Size(900, 900)); var testCalls = 0; + await tester.pumpWidget( _EditorLauncher( onTest: ({required kind, required baseUrl, apiKey, username, password}) async { @@ -302,13 +292,12 @@ void main() { }, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://prowlarr.local'); - await tester.tap(find.byKey(const Key('acquisition-test-connection'))); await tester.pumpAndSettle(); - expect(testCalls, 1); expect(find.text('Enter a name'), findsNothing); expect(find.text('Connection successful.'), findsOneWidget); @@ -317,6 +306,7 @@ void main() { testWidgets('testing a corrected URL clears the error from a failed save', (tester) async { await _setWindowSize(tester, const Size(900, 900)); var testCalls = 0; + await tester.pumpWidget( _EditorLauncher( onTest: ({required kind, required baseUrl, apiKey, username, password}) async { @@ -324,19 +314,17 @@ void main() { }, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await tester.enterText(find.byKey(const Key('acquisition-name')), 'Prowlarr'); await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://'); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); expect(find.text('Enter a valid server URL'), findsOneWidget); - await tester.enterText(find.byKey(const Key('acquisition-url')), 'https://prowlarr.local'); await tester.tap(find.byKey(const Key('acquisition-test-connection'))); await tester.pumpAndSettle(); - expect(testCalls, 1); expect(find.text('Enter a valid server URL'), findsNothing); expect(find.text('Connection successful.'), findsOneWidget); @@ -344,25 +332,26 @@ void main() { testWidgets('reports connection errors locally', (tester) async { await _setWindowSize(tester, const Size(900, 900)); + await tester.pumpWidget( _EditorLauncher( onTest: ({required kind, required baseUrl, apiKey, username, password}) => Future.error(const AuthApiException(statusCode: 502, message: 'Prowlarr connection test failed')), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await _enterRequiredFields(tester); - await tester.tap(find.byKey(const Key('acquisition-test-connection'))); await tester.pumpAndSettle(); - expect(find.text('Prowlarr connection test failed'), findsOneWidget); }); testWidgets('failed save restores idle drag and handle behavior', (tester) async { final saveCompleter = Completer(); await _setWindowSize(tester, const Size(390, 844)); + await tester.pumpWidget( _EditorLauncher( onSave: @@ -378,28 +367,23 @@ void main() { }) => saveCompleter.future, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await _enterRequiredFields(tester); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); - var bottomSheet = tester.widget(find.byType(BottomSheet)); expect(bottomSheet.enableDrag, isFalse); expect(bottomSheet.showDragHandle, isFalse); - saveCompleter.completeError(const AuthApiException(statusCode: 502, message: 'Could not save integration')); await tester.pumpAndSettle(); - bottomSheet = tester.widget(find.byType(BottomSheet)); expect(bottomSheet.enableDrag, isTrue); expect(bottomSheet.showDragHandle, isFalse); expect(find.byType(BottomSheetHandle), findsOneWidget); - await tester.drag(find.byType(BottomSheet), const Offset(0, 700)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-endpoint-sheet')), findsNothing); }); @@ -411,14 +395,11 @@ void main() { await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await _enterRequiredFields(tester); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); expect(result, isNull); - saveCompleter.complete(); await tester.pumpAndSettle(); - expect(result, isTrue); expect(find.byType(AcquisitionEndpointEditor), findsNothing); }); @@ -442,6 +423,7 @@ void main() { String? savedApiKey = 'not-called'; String? savedUsername = 'not-called'; String? savedPassword = 'not-called'; + await tester.pumpWidget( _EditorLauncher( endpoint: _endpoint, @@ -462,12 +444,11 @@ void main() { }, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(savedApiKey, isNull); expect(savedUsername, isNull); expect(savedPassword, isNull); @@ -478,6 +459,7 @@ void main() { String? savedUsername = 'not-called'; String? savedPassword = 'not-called'; String? savedDownloadRoot; + await tester.pumpWidget( _EditorLauncher( endpoint: _downloadClient, @@ -498,15 +480,13 @@ void main() { }, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-username')), findsOneWidget); expect(find.byKey(const Key('acquisition-password')), findsOneWidget); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pumpAndSettle(); - expect(savedUsername, isNull); expect(savedPassword, isNull); expect(savedDownloadRoot, '/downloads'); @@ -585,6 +565,7 @@ class _SaveRaceLauncher extends StatelessWidget { onPressed: () async { late BuildContext editorContext; var wasBusy = false; + final result = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -662,14 +643,15 @@ Future _expectIdleSheetDismisses(WidgetTester tester, _DismissAttempt atte case _DismissAttempt.drag: await tester.drag(find.byType(BottomSheet), const Offset(0, 700)); } - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byKey(const Key('acquisition-endpoint-sheet')), findsNothing); } Future _expectPendingSaveBlocksDismissal(WidgetTester tester, _DismissAttempt attempt) async { await _setWindowSize(tester, const Size(390, 844)); final saveCompleter = Completer(); + await tester.pumpWidget( _EditorLauncher( onSave: @@ -685,13 +667,12 @@ Future _expectPendingSaveBlocksDismissal(WidgetTester tester, _DismissAtte }) => saveCompleter.future, ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); await _enterRequiredFields(tester); - await tester.tap(find.byKey(const Key('acquisition-save'))); await tester.pump(); - expect(tester.widget(find.widgetWithText(OutlinedButton, 'Cancel')).onPressed, isNull); final bottomSheet = tester.widget(find.byType(BottomSheet)); expect(bottomSheet.enableDrag, isFalse); @@ -705,13 +686,11 @@ Future _expectPendingSaveBlocksDismissal(WidgetTester tester, _DismissAtte case _DismissAttempt.drag: await tester.drag(find.byType(BottomSheet), const Offset(0, 700)); } - await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 500)); expect(find.byKey(const Key('acquisition-endpoint-sheet')), findsOneWidget); - saveCompleter.complete(); await tester.pumpAndSettle(); - expect(find.byKey(const Key('acquisition-endpoint-sheet')), findsNothing); } diff --git a/app/test/widgets/acquisition/acquisition_settings_section_test.dart b/app/test/widgets/acquisition/acquisition_settings_section_test.dart index 48a062ab..5187bf56 100644 --- a/app/test/widgets/acquisition/acquisition_settings_section_test.dart +++ b/app/test/widgets/acquisition/acquisition_settings_section_test.dart @@ -21,7 +21,6 @@ void main() { testWidgets('empty section uses one SettingsCard and no literal Card', (tester) async { await pump(tester, const AcquisitionSettingsSection(title: 'Sources', emptyMessage: 'No sources configured')); - expect(find.byType(AcquisitionSettingsSection), findsOneWidget); expect(find.byType(SettingsCard), findsOneWidget); expect(find.byType(Card), findsNothing); @@ -41,7 +40,6 @@ void main() { final headerBounds = tester.getRect(find.text('Download clients')); final messageBounds = tester.getRect(find.text('No download clients configured')); final cardBounds = tester.getRect(find.byType(SettingsCard)); - expect(find.byType(EmptyState), findsOneWidget); expect(messageBounds.center.dx, cardBounds.center.dx); expect(messageBounds.top, greaterThan(headerBounds.bottom)); @@ -58,16 +56,13 @@ void main() { await tester.tap(find.widgetWithText(TextButton, 'Add')); await tester.pump(); - expect(addCalls, 1); }); testWidgets('title is a header and Add exposes one contextual button node', (tester) async { await pump(tester, AcquisitionSettingsSection(title: 'Sources', onAdd: () {})); - final header = tester.getSemantics(find.text('Sources')); final add = tester.getSemantics(find.bySemanticsLabel('Add to Sources')); - expect(header.flagsCollection.isHeader, isTrue); expect(find.bySemanticsLabel('Add to Sources'), findsOneWidget); expect(add.flagsCollection.isButton, isTrue); @@ -119,6 +114,7 @@ void main() { expect(find.text('Enabled'), findsOneWidget); expect(find.byKey(const Key('custom-trailing')), findsOneWidget); expect(find.byIcon(Icons.chevron_right), findsNothing); + expect( tester.getCenter(find.text('Prowlarr')).dx, lessThan(tester.getCenter(find.byKey(const Key('custom-trailing'))).dx), @@ -127,7 +123,6 @@ void main() { testWidgets('value-only SettingsRow shows one value aligned to the right', (tester) async { await pump(tester, const SettingsRow(label: 'Server support', value: 'Available', showChevron: false)); - expect(find.text('Available'), findsOneWidget); expect(tester.getCenter(find.text('Server support')).dx, lessThan(tester.getCenter(find.text('Available')).dx)); expect(tester.getCenter(find.text('Server support')).dy, tester.getCenter(find.text('Available')).dy); @@ -154,7 +149,6 @@ void main() { testWidgets('SettingsRow meets the mobile target and grows for multiline content', (tester) async { await pump(tester, const SettingsRow(label: 'Short row', showChevron: false)); - expect(tester.getSize(find.byType(SettingsRow)).height, greaterThanOrEqualTo(TouchTargets.mobileRecommended)); await pump( diff --git a/app/test/widgets/acquisition/guarded_bottom_sheet_route_test.dart b/app/test/widgets/acquisition/guarded_bottom_sheet_route_test.dart index abd3908a..71f5f59c 100644 --- a/app/test/widgets/acquisition/guarded_bottom_sheet_route_test.dart +++ b/app/test/widgets/acquisition/guarded_bottom_sheet_route_test.dart @@ -8,32 +8,24 @@ void main() { final busy = ValueNotifier(false); addTearDown(busy.dispose); await tester.pumpWidget(_RouteLauncher(busy: busy)); - await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - var sheet = tester.widget(find.byType(BottomSheet)); expect(sheet.enableDrag, isTrue); expect(sheet.showDragHandle, isFalse); - busy.value = true; await tester.pump(); - sheet = tester.widget(find.byType(BottomSheet)); expect(sheet.enableDrag, isFalse); expect(sheet.showDragHandle, isFalse); - await tester.tapAt(const Offset(4, 4)); await tester.pumpAndSettle(); expect(find.byKey(const Key('guarded-sheet-content')), findsOneWidget); - busy.value = false; await tester.pump(); - sheet = tester.widget(find.byType(BottomSheet)); expect(sheet.enableDrag, isTrue); expect(sheet.showDragHandle, isFalse); - await tester.tapAt(const Offset(4, 4)); await tester.pumpAndSettle(); expect(find.byKey(const Key('guarded-sheet-content')), findsNothing); diff --git a/app/test/widgets/add_book/add_book_sheet_scaffold_test.dart b/app/test/widgets/add_book/add_book_sheet_scaffold_test.dart index 78ff8e33..c15f27dd 100644 --- a/app/test/widgets/add_book/add_book_sheet_scaffold_test.dart +++ b/app/test/widgets/add_book/add_book_sheet_scaffold_test.dart @@ -27,13 +27,10 @@ void main() { expect(find.byKey(const Key('add-book-sheet-header')), findsOneWidget); expect(find.byKey(const Key('add-book-sheet-footer')), findsOneWidget); - final headerTopBeforeScroll = tester.getTopLeft(find.byKey(const Key('add-book-sheet-header'))).dy; final footerTopBeforeScroll = tester.getTopLeft(find.byKey(const Key('add-book-sheet-footer'))).dy; - await tester.drag(find.byKey(const Key('scrolling-body')), const Offset(0, -300)); await tester.pumpAndSettle(); - expect(scrollController.offset, greaterThan(0)); expect(tester.getTopLeft(find.byKey(const Key('add-book-sheet-header'))).dy, headerTopBeforeScroll); expect(tester.getTopLeft(find.byKey(const Key('add-book-sheet-footer'))).dy, footerTopBeforeScroll); diff --git a/app/test/widgets/add_book/add_book_sheets_test.dart b/app/test/widgets/add_book/add_book_sheets_test.dart index 8ec9a350..2a81b99e 100644 --- a/app/test/widgets/add_book/add_book_sheets_test.dart +++ b/app/test/widgets/add_book/add_book_sheets_test.dart @@ -52,9 +52,9 @@ void main() { (context) => () => AddBookChoiceSheet.show(context), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsOneWidget); expect(find.byType(Dialog), findsNothing); expect(find.byType(BottomSheetHandle), findsOneWidget); @@ -67,9 +67,9 @@ void main() { (context) => () => AddBookChoiceSheet.show(context), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(find.text('Find books online'), findsNothing); }); @@ -116,7 +116,6 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Open add book')); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.travel_explore_outlined), findsOneWidget); expect(find.text('Find books online'), findsOneWidget); expect(find.text('Search connected book sources'), findsOneWidget); @@ -128,7 +127,6 @@ void main() { onlineOption.onTap!(); onlineOption.onTap!(); await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsNothing); expect(findOnlineCalls, 1); expect(sheetWasAbsentAtCallback, isTrue); @@ -139,6 +137,7 @@ void main() { testWidgets('digital import uses one unified modal route', (tester) async { final observer = _CountingNavigatorObserver(); final selected = SelectedBookFile(name: 'selected.epub', bytes: Uint8List.fromList([1])); + await pumpLauncher( tester, (context) => @@ -158,27 +157,26 @@ void main() { navigatorObservers: [observer], theme: AppTheme.dark.copyWith(platform: TargetPlatform.linux), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - final initialPushCount = observer.pushCount; - await tester.tap(find.text('Import digital books')); await tester.pumpAndSettle(); - expect(find.text('Browse files'), findsOneWidget); expect(find.byType(BookImportDropZone), findsOneWidget); expect(find.text('Add book'), findsNothing); expect(observer.pushCount, initialPushCount + 1); - final sheetRect = tester.getRect(find.byKey(const Key('add-book-sheet-header'))); + final dropZoneRect = tester.getRect( find.descendant(of: find.byType(BookImportDropZone), matching: find.byType(AnimatedContainer)), ); + expect(dropZoneRect.left, sheetRect.left + 24); expect(dropZoneRect.right, sheetRect.right - 24); - final dropTarget = tester.widget(find.byType(DropTarget)); + dropTarget.onDragDone!( DropDoneDetails( files: [ @@ -188,11 +186,10 @@ void main() { globalPosition: Offset.zero, ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.text('dropped.epub'), findsOneWidget); expect(find.byType(BookImportDropZone), findsNothing); - await tester.tap(find.widgetWithText(TextButton, 'Reset')); await tester.pump(); await tester.tap(find.text('Browse files')); @@ -204,20 +201,19 @@ void main() { testWidgets('physical choice dismisses before opening its separate sheet', (tester) async { final observer = _CountingNavigatorObserver(); + await pumpLauncher( tester, (context) => () => AddBookChoiceSheet.show(context), navigatorObservers: [observer], ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - final initialPushCount = observer.pushCount; - await tester.tap(find.text('Add physical book')); await tester.pumpAndSettle(); - expect(find.text('Add physical book'), findsOneWidget); expect(find.byKey(const Key('add-book-sheet-header')), findsOneWidget); expect(find.text('Add book'), findsNothing); @@ -230,6 +226,7 @@ void main() { (context) => () => AddPhysicalBookSheet.show(context), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); @@ -240,6 +237,7 @@ void main() { ), findsOneWidget, ); + expect( find.ancestor( of: find.widgetWithText(FilledButton, 'Add'), @@ -264,21 +262,18 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); expect(find.byKey(const Key('add-book-sheet-footer')), findsOneWidget); expect(tester.getRect(find.byKey(const Key('add-book-sheet-footer'))).bottom, lessThanOrEqualTo(150)); - final listView = find.byType(ListView); final scrollable = tester.state(find.byType(Scrollable)); expect(tester.getSize(listView).height, greaterThan(0)); expect(scrollable.position.maxScrollExtent, greaterThan(0)); - scrollable.position.jumpTo(1); await tester.pump(); - expect(scrollable.position.pixels, 1); }); @@ -299,9 +294,9 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); - expect(tester.takeException(), isNull); expect(tester.getRect(find.byKey(const Key('add-book-sheet-footer'))).bottom, lessThanOrEqualTo(150)); @@ -309,9 +304,9 @@ void main() { of: find.byKey(const Key('add-book-sheet-header')), matching: find.byType(IconButton), ); + expect(tester.getSize(closeButton).width, greaterThanOrEqualTo(44)); expect(tester.getSize(closeButton).height, greaterThanOrEqualTo(44)); - final scrollable = tester.state(find.byType(Scrollable)); expect(tester.getSize(find.byType(ListView)).height, greaterThan(0)); expect(scrollable.position.maxScrollExtent, greaterThan(0)); diff --git a/app/test/widgets/add_book/book_import_batch_item_test.dart b/app/test/widgets/add_book/book_import_batch_item_test.dart index 25d582d0..cecba896 100644 --- a/app/test/widgets/add_book/book_import_batch_item_test.dart +++ b/app/test/widgets/add_book/book_import_batch_item_test.dart @@ -6,6 +6,7 @@ import 'package:papyrus/widgets/add_book/book_import_batch_item.dart'; void main() { final file = SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1, 2, 3])); + const result = BookImportResult( bookId: 'book-1', title: 'Test Book', @@ -27,13 +28,11 @@ void main() { expect(failed.canRetry, isTrue); expect(failed.isSettled, isTrue); expect(failed.hasTemporaryFile, isFalse); - final processing = failed.startProcessing(); expect(processing.id, 'row-1'); expect(processing.status, BookImportBatchStatus.processing); expect(processing.result, isNull); expect(processing.errorMessage, isNull); - final ready = processing.processingSucceeded(result); expect(ready.id, 'row-1'); expect(ready.status, BookImportBatchStatus.ready); @@ -46,7 +45,6 @@ void main() { test('commit failure retains result and can retry adding', () { final ready = BookImportBatchItem.queued(id: 'row-2', file: file).startProcessing().processingSucceeded(result); - final failed = ready.startAdding().commitFailed('Could not add the book.'); expect(failed.id, 'row-2'); expect(failed.status, BookImportBatchStatus.commitFailed); @@ -55,13 +53,11 @@ void main() { expect(failed.canRetry, isTrue); expect(failed.isSettled, isTrue); expect(failed.hasTemporaryFile, isTrue); - final adding = failed.startAdding(); expect(adding.id, 'row-2'); expect(adding.status, BookImportBatchStatus.adding); expect(adding.result, same(result)); expect(adding.errorMessage, isNull); - final added = adding.added(); expect(added.id, 'row-2'); expect(added.status, BookImportBatchStatus.added); @@ -75,7 +71,6 @@ void main() { final processing = queued.startProcessing(); final ready = processing.processingSucceeded(result); final adding = ready.startAdding(); - expect(() => queued.processingSucceeded(result), throwsStateError); expect(() => queued.processingFailed('Could not read the file.'), throwsStateError); expect(() => ready.startProcessing(), throwsStateError); @@ -92,7 +87,6 @@ void main() { final adding = ready.startAdding(); final added = adding.added(); final commitFailed = adding.commitFailed('Could not add the book.'); - expect(queued.isSettled, isFalse); expect(processing.isSettled, isFalse); expect(ready.isSettled, isTrue); diff --git a/app/test/widgets/add_book/book_import_controller_test.dart b/app/test/widgets/add_book/book_import_controller_test.dart index 19b2903f..0547350e 100644 --- a/app/test/widgets/add_book/book_import_controller_test.dart +++ b/app/test/widgets/add_book/book_import_controller_test.dart @@ -12,6 +12,7 @@ void main() { final committed = Completer(); var attempts = 0; final deleted = []; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), @@ -19,10 +20,14 @@ void main() { processor: (_, filename) async => _result(filename), deleteBookFile: (id) async => deleted.add(id), committer: (_, filename) { - if (++attempts == 1) throw StateError('Retry'); + if (++attempts == 1) { + throw StateError('Retry'); + } + return committed.future; }, ); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); @@ -42,6 +47,7 @@ void main() { test('partial commit retry preserves successful books without reprocessing', () async { final commits = {}; var parses = 0; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'good.epub', bytes: Uint8List.fromList([1])), @@ -54,10 +60,15 @@ void main() { deleteBookFile: (_) async {}, committer: (_, filename) async { commits[filename] = (commits[filename] ?? 0) + 1; - if (filename == 'retry.epub' && commits[filename] == 1) throw Exception('Try again'); + + if (filename == 'retry.epub' && commits[filename] == 1) { + throw Exception('Try again'); + } + return _book(filename); }, ); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); @@ -73,6 +84,7 @@ void main() { test('close can retry temporary cleanup without repeating the import', () async { var deletions = 0; var parses = 0; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), @@ -82,10 +94,13 @@ void main() { return _result(filename); }, deleteBookFile: (_) async { - if (++deletions == 1) throw Exception('Busy'); + if (++deletions == 1) { + throw Exception('Busy'); + } }, committer: (_, _) async => throw Exception('Commit failed'), ); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); @@ -99,6 +114,7 @@ void main() { test('close and remove share in-flight temporary cleanup', () async { final deletion = Completer(); var deletions = 0; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), @@ -110,6 +126,7 @@ void main() { }, committer: (_, _) async => throw Exception('Commit failed'), ); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); @@ -125,6 +142,7 @@ void main() { test('picker cancellation preserves the previous selection', () async { var picks = 0; + final controller = BookImportController( pickFiles: () async => ++picks == 1 ? [ @@ -135,6 +153,7 @@ void main() { deleteBookFile: (_) async {}, committer: (_, filename) async => _book(filename), ); + addTearDown(controller.dispose); await controller.browse(); await controller.browse(); @@ -148,6 +167,7 @@ void main() { deleteBookFile: (_) async {}, committer: (result, filename) async => _book(filename), ); + addTearDown(controller.dispose); controller.applyDroppedFiles([ @@ -165,16 +185,16 @@ void main() { deleteBookFile: (_) async {}, committer: (result, filename) async => _book(filename), ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); controller.applyDroppedFiles(const [], feedback: 'No supported book files were dropped.'); - expect(controller.files, isEmpty); expect(controller.pickerError, 'No supported book files were dropped.'); }); test('imports selected files and reports completion once', () async { final completed = >[]; + final result = BookImportResult( bookId: 'book-1', title: 'Imported book', @@ -183,7 +203,9 @@ void main() { fileHash: 'hash', fileExtension: 'epub', ); + final book = Book(id: 'book-1', title: 'Imported book', author: 'Author', addedAt: DateTime(2026)); + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1, 2, 3])), @@ -193,14 +215,14 @@ void main() { committer: (result, filename) async => book, onCompleted: completed.add, ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); await pumpEventQueue(); - expect(controller.phase, BookImportPhase.summary); expect(controller.items.single.status, BookImportBatchStatus.added); + expect(completed, [ [book], ]); @@ -208,6 +230,7 @@ void main() { test('retries processing failures through the full pipeline', () async { var attempts = 0; + final result = BookImportResult( bookId: 'book-1', title: 'Imported book', @@ -216,29 +239,33 @@ void main() { fileHash: 'hash', fileExtension: 'epub', ); + final book = Book(id: 'book-1', title: 'Imported book', author: 'Author', addedAt: DateTime(2026)); + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), ], processor: (bytes, filename) async { attempts++; - if (attempts == 1) throw Exception('Invalid book'); + + if (attempts == 1) { + throw Exception('Invalid book'); + } + return result; }, deleteBookFile: (_) async {}, committer: (result, filename) async => book, ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); await pumpEventQueue(); expect(controller.items.single.status, BookImportBatchStatus.processingFailed); - await controller.retryItem(controller.items.single.id); await pumpEventQueue(); - expect(attempts, 2); expect(controller.items.single.status, BookImportBatchStatus.added); }); @@ -247,6 +274,7 @@ void main() { final firstCommit = Completer(); final secondCommit = Completer(); final completed = >[]; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'first.epub', bytes: Uint8List.fromList([1])), @@ -257,17 +285,15 @@ void main() { committer: (result, filename) => filename == 'first.epub' ? firstCommit.future : secondCommit.future, onCompleted: completed.add, ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); await pumpEventQueue(); - firstCommit.complete(_book('first.epub')); await pumpEventQueue(); expect(controller.phase, BookImportPhase.processing); expect(completed, isEmpty); - secondCommit.complete(_book('second.epub')); await pumpEventQueue(); expect(controller.phase, BookImportPhase.summary); @@ -278,6 +304,7 @@ void main() { var commitAttempts = 0; final retryCommit = Completer(); final deleted = []; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), @@ -286,26 +313,27 @@ void main() { deleteBookFile: (bookId) async => deleted.add(bookId), committer: (result, filename) { commitAttempts++; - if (commitAttempts == 1) throw Exception('Commit failed'); + + if (commitAttempts == 1) { + throw Exception('Commit failed'); + } + return retryCommit.future; }, ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); await pumpEventQueue(); expect(controller.items.single.status, BookImportBatchStatus.commitFailed); - unawaited(controller.retryItem(controller.items.single.id)); await pumpEventQueue(); var closeCompleted = false; final close = controller.requestClose()..whenComplete(() => closeCompleted = true); await pumpEventQueue(); - expect(closeCompleted, isFalse); expect(deleted, isEmpty); - retryCommit.complete(_book('book.epub')); expect(await close, BookImportCloseResult.closed); expect(deleted, isEmpty); @@ -314,6 +342,7 @@ void main() { test('close succeeds after cleaning a parse result that finishes late', () async { final processing = Completer(); final deleted = []; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), @@ -322,14 +351,13 @@ void main() { deleteBookFile: (bookId) async => deleted.add(bookId), committer: (result, filename) async => _book(filename), ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); await pumpEventQueue(); final close = controller.requestClose(); processing.complete(_result('book.epub')); - expect(await close, BookImportCloseResult.closed); expect(deleted, ['book.epub']); }); @@ -337,6 +365,7 @@ void main() { test('failed late-result cleanup remains retryable', () async { final processing = Completer(); var deleteAttempts = 0; + final controller = BookImportController( pickFiles: () async => [ SelectedBookFile(name: 'book.epub', bytes: Uint8List.fromList([1])), @@ -344,18 +373,20 @@ void main() { processor: (bytes, filename) => processing.future, deleteBookFile: (_) async { deleteAttempts++; - if (deleteAttempts == 1) throw Exception('Delete failed'); + + if (deleteAttempts == 1) { + throw Exception('Delete failed'); + } }, committer: (result, filename) async => _book(filename), ); - addTearDown(controller.dispose); + addTearDown(controller.dispose); await controller.browse(); controller.startImport(); await pumpEventQueue(); final firstClose = controller.requestClose(); processing.complete(_result('book.epub')); - expect(await firstClose, BookImportCloseResult.processingCleanupFailed); expect(controller.items.single.status, BookImportBatchStatus.ready); expect(await controller.requestClose(), BookImportCloseResult.closed); diff --git a/app/test/widgets/add_book/book_import_sheet_test.dart b/app/test/widgets/add_book/book_import_sheet_test.dart index de19337f..5e45d1a0 100644 --- a/app/test/widgets/add_book/book_import_sheet_test.dart +++ b/app/test/widgets/add_book/book_import_sheet_test.dart @@ -19,6 +19,7 @@ void main() { tester.view.physicalSize = const Size(390, 844); addTearDown(tester.view.reset); var picks = 0; + await tester.pumpWidget( MaterialApp( theme: ThemeData(platform: TargetPlatform.android), @@ -29,6 +30,7 @@ void main() { context, pickFiles: () async { picks++; + return List.generate( 12, (i) => SelectedBookFile(name: 'batch-$picks-$i.epub', bytes: Uint8List.fromList([1])), @@ -44,6 +46,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open import')); await tester.pumpAndSettle(); expect(find.byType(ExpandableBottomSheet), findsOneWidget); @@ -56,11 +59,13 @@ void main() { expect(tester.getSize(cancel).height, tester.getSize(initialImport).height); expect(tester.getTopLeft(cancel).dy, tester.getTopLeft(initialImport).dy); expect(tester.getTopLeft(initialImport).dx - tester.getTopRight(cancel).dx, 8); + final primaryShape = tester .widget(initialImport) .defaultStyleOf(tester.element(initialImport)) .shape! .resolve({}); + final secondaryShape = OutlinedButtonTheme.of(tester.element(cancel)).style!.shape!.resolve({}); expect(primaryShape, secondaryShape); expect(primaryShape, isA()); @@ -91,9 +96,11 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(320, 568); addTearDown(tester.view.reset); + final files = [ SelectedBookFile(name: 'a-long-book-filename.epub', bytes: Uint8List.fromList([1])), ]; + await tester.pumpWidget( MaterialApp( builder: (context, child) => MediaQuery( @@ -115,6 +122,7 @@ void main() { ), ), ); + expect(tester.takeException(), isNull); expect(find.text('Import'), findsOneWidget); expect(tester.getSize(find.byTooltip('Remove a-long-book-filename.epub')).width, greaterThanOrEqualTo(48)); @@ -139,6 +147,7 @@ void main() { ), ), ); + final browse = find.widgetWithText(OutlinedButton, 'Browse files'); final cancel = find.widgetWithText(OutlinedButton, 'Cancel'); final import = find.widgetWithText(FilledButton, 'Import'); @@ -169,6 +178,7 @@ void main() { final deleted = Completer(); final deletions = []; final navigator = GlobalKey(); + await tester.pumpWidget( MaterialApp( navigatorKey: navigator, @@ -193,6 +203,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open import')); await tester.pumpAndSettle(); await tester.tap(find.text('Browse files')); @@ -202,6 +213,7 @@ void main() { await navigator.currentState!.maybePop(); await tester.pump(); expect(find.byType(BookImportSheet), findsOneWidget); + processed.complete( const BookImportResult( bookId: 'temporary', @@ -212,6 +224,7 @@ void main() { fileExtension: 'epub', ), ); + await tester.pump(); expect(deletions, ['temporary']); expect(find.byType(BookImportSheet), findsOneWidget); @@ -245,7 +258,6 @@ void main() { expect(find.text('EPUB, PDF, MOBI, AZW3, TXT, CBR, and CBZ'), findsOneWidget); expect(tester.getSize(find.widgetWithText(OutlinedButton, 'Browse files')).width, lessThan(200)); expect(tester.widget(find.text('Browse files')).maxLines, 1); - await tester.tap(find.widgetWithText(OutlinedButton, 'Browse files')); expect(browseCount, 1); }); @@ -269,7 +281,6 @@ void main() { final focusable = tester.widget(find.byType(FocusableActionDetector)); focusable.onShowHoverHighlight?.call(true); await tester.pumpAndSettle(); - expect(surfaceColor(), restingColor); }); @@ -351,6 +362,7 @@ void main() { ); final dropTarget = tester.widget(find.byType(DropTarget)); + dropTarget.onDragDone!( DropDoneDetails( files: [ @@ -361,8 +373,8 @@ void main() { globalPosition: Offset.zero, ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(droppedFiles, hasLength(1)); expect(droppedFiles!.single.name, 'book.epub'); expect(droppedFiles!.single.bytes, [1, 2]); diff --git a/app/test/widgets/app_motion_control_test.dart b/app/test/widgets/app_motion_control_test.dart index 0151da5e..bab5049a 100644 --- a/app/test/widgets/app_motion_control_test.dart +++ b/app/test/widgets/app_motion_control_test.dart @@ -7,7 +7,6 @@ import 'package:papyrus/widgets/shared/app_motion_control.dart'; void main() { final themes = {'light': AppTheme.light, 'dark': AppTheme.dark, 'eink': AppTheme.eink}; - ToggleableStateMixin toggleState(WidgetTester tester) => tester.allStates.whereType().single; testWidgets('chip motion refreshes across theme switches and retains keyboard focus', (tester) async { @@ -17,6 +16,7 @@ void main() { addTearDown(theme.dispose); addTearDown(selected.dispose); addTearDown(focus.dispose); + await tester.pumpWidget( ValueListenableBuilder( valueListenable: theme, @@ -44,6 +44,7 @@ void main() { ), ), ); + focus.requestFocus(); await tester.pumpAndSettle(); await tester.sendKeyEvent(LogicalKeyboardKey.space); @@ -81,6 +82,7 @@ void main() { testWidgets('${theme.key} $kind paints changed values with the expected motion', (tester) async { final value = ValueNotifier(false); addTearDown(value.dispose); + await tester.pumpWidget( MaterialApp( theme: theme.value, @@ -96,10 +98,12 @@ void main() { onChanged: (changed) => value.value = changed, ); } + return AppMotionControl( value: selected, builder: (focusNode) { void changed(bool? changed) => value.value = changed!; + return switch (kind) { 'checkbox' => Checkbox(value: selected, onChanged: changed, focusNode: focusNode), 'switch' => Switch(value: selected, onChanged: changed, focusNode: focusNode), @@ -124,6 +128,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(toggleState(tester).position.value, 0); final target = kind.contains('checkbox') ? find.byType(Checkbox) : find.byType(Switch); @@ -131,6 +136,7 @@ void main() { await tester.pump(); await tester.pump(); expect(value.value, isTrue); + if (theme.key == 'eink') { expect(toggleState(tester).position.value, 1); expect(toggleState(tester).positionController.isAnimating, isFalse); @@ -138,12 +144,17 @@ void main() { await tester.pump(const Duration(milliseconds: 40)); expect(toggleState(tester).position.value, inExclusiveRange(0, 1)); } + await tester.pumpAndSettle(); await tester.tap(target); await tester.pump(); await tester.pump(); expect(value.value, isFalse); - if (theme.key == 'eink') expect(toggleState(tester).position.value, 0); + + if (theme.key == 'eink') { + expect(toggleState(tester).position.value, 0); + } + await tester.pumpAndSettle(); }); } @@ -155,6 +166,7 @@ void main() { final focus = FocusNode(); addTearDown(value.dispose); addTearDown(focus.dispose); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -177,8 +189,10 @@ void main() { ), ), ); + focus.requestFocus(); await tester.pumpAndSettle(); + for (final expected in [true, false, true]) { await tester.sendKeyEvent(LogicalKeyboardKey.space); await tester.pump(); @@ -195,6 +209,7 @@ void main() { testWidgets('${theme.key} $kind chip selection uses the expected animation style', (tester) async { final value = ValueNotifier(false); addTearDown(value.dispose); + await tester.pumpWidget( MaterialApp( theme: theme.value, @@ -204,6 +219,7 @@ void main() { valueListenable: value, builder: (context, selected, _) { final style = appChipAnimationStyle(context); + return AppMotionControl( value: null, builder: (focusNode) => switch (kind) { @@ -236,14 +252,17 @@ void main() { ), ), ); + await tester.pumpAndSettle(); await tester.tap(find.text('Option')); await tester.pump(); await tester.pump(); expect(value.value, isTrue); final chip = tester.widget(find.byType(RawChip)); + if (theme.key == 'eink') { final style = chip.chipAnimationStyle!; + for (final animation in [ style.enableAnimation, style.selectAnimation, @@ -253,6 +272,7 @@ void main() { expect(animation!.duration, Duration.zero); expect(animation.reverseDuration, Duration.zero); } + final immediateWidth = tester.getSize(find.byType(RawChip)).width; await tester.pumpAndSettle(); expect(tester.getSize(find.byType(RawChip)).width, immediateWidth); diff --git a/app/test/widgets/app_motion_integration_test.dart b/app/test/widgets/app_motion_integration_test.dart index 81296637..bdee76f8 100644 --- a/app/test/widgets/app_motion_integration_test.dart +++ b/app/test/widgets/app_motion_integration_test.dart @@ -46,12 +46,14 @@ void main() { tester, ) async { await desktop(tester); + await tester.pumpWidget( MaterialApp( theme: entry.value, home: Scaffold(body: card()), ), ); + await tester.pumpAndSettle(); expect(opacity(tester), 0); final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); @@ -59,12 +61,14 @@ void main() { await mouse.moveTo(tester.getCenter(find.byType(ShelfCard))); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); + if (entry.key == 'eink') { expect(opacity(tester), 1); } else { expect(opacity(tester), greaterThan(0)); expect(opacity(tester), lessThan(1)); } + await mouse.removePointer(); await tester.pumpAndSettle(); }); @@ -74,6 +78,7 @@ void main() { await desktop(tester); final theme = ValueNotifier(AppTheme.light); addTearDown(theme.dispose); + await tester.pumpWidget( ValueListenableBuilder( valueListenable: theme, @@ -84,6 +89,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); await mouse.addPointer(location: Offset.zero); @@ -105,6 +111,7 @@ void main() { final navigator = GlobalKey(); final busy = ValueNotifier(false); addTearDown(busy.dispose); + await tester.pumpWidget( MaterialApp( theme: entry.value, @@ -121,6 +128,7 @@ void main() { bookTitle: 'Book', ); } + return TextButton( onPressed: () { switch (overlay) { @@ -163,11 +171,13 @@ void main() { ), ), ); + await tester.pumpAndSettle(); await tester.tap(overlay == 'popup' ? find.byIcon(Icons.sort) : find.text('Open')); await tester.pump(); await tester.pump(); final route = routes.latest! as TransitionRoute; + if (entry.key == 'eink') { expect(route.transitionDuration, Duration.zero); expect(route.animation!.value, 1); @@ -177,9 +187,11 @@ void main() { expect(route.animation!.value, inExclusiveRange(0, 1)); await tester.pumpAndSettle(); } + navigator.currentState!.pop(); await tester.pump(); await tester.pump(); + if (entry.key == 'eink') { expect(route.isActive, isFalse); expect(navigator.currentState!.canPop(), isFalse); diff --git a/app/test/widgets/auth/auth_page_layouts_test.dart b/app/test/widgets/auth/auth_page_layouts_test.dart index 46ee360b..7fcf49ee 100644 --- a/app/test/widgets/auth/auth_page_layouts_test.dart +++ b/app/test/widgets/auth/auth_page_layouts_test.dart @@ -28,7 +28,6 @@ void main() { ); final branding = find.byType(AuthBranding); - expect(find.text('Papyrus'), findsOneWidget); expect(branding, findsOneWidget); expect(tester.getTopLeft(branding).dx, Spacing.xl); @@ -50,7 +49,6 @@ void main() { ); final branding = find.byType(AuthBranding); - expect(find.text('Papyrus'), findsOneWidget); expect(branding, findsOneWidget); expect(tester.getCenter(branding).dx, moreOrLessEquals(195)); @@ -78,9 +76,9 @@ void main() { ), ), ); + await tester.drag(find.byType(CustomScrollView), const Offset(0, -700)); await tester.pumpAndSettle(); - final button = find.widgetWithText(TextButton, 'Continue offline'); expect(tester.getRect(button).bottom, lessThanOrEqualTo(keyboard ? 400 : 652)); expect(tester.takeException(), isNull); @@ -91,7 +89,6 @@ void main() { testWidgets('desktop swap button is focused after form controls', (tester) async { setViewport(tester, const Size(1200, 800)); - final firstFocusNode = FocusNode(debugLabel: 'first'); final secondFocusNode = FocusNode(debugLabel: 'second'); final submitFocusNode = FocusNode(debugLabel: 'submit'); @@ -123,24 +120,20 @@ void main() { firstFocusNode.requestFocus(); await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(secondFocusNode.hasFocus, isTrue); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(submitFocusNode.hasFocus, isTrue); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); expect(footerFocusNode.hasFocus, isTrue); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); await tester.pump(); - final focusedContext = FocusManager.instance.primaryFocus?.context; expect(focusedContext, isNotNull); + expect( find.descendant(of: find.byWidget(focusedContext!.widget), matching: find.byIcon(Icons.swap_horiz)), findsOneWidget, diff --git a/app/test/widgets/book/book_annotations_test.dart b/app/test/widgets/book/book_annotations_test.dart index 9b070180..72bd6f21 100644 --- a/app/test/widgets/book/book_annotations_test.dart +++ b/app/test/widgets/book/book_annotations_test.dart @@ -68,25 +68,21 @@ void main() { group('rendering', () { testWidgets('displays search field with hint text', (tester) async { await tester.pumpWidget(buildAnnotations()); - expect(find.text('Search annotations...'), findsOneWidget); }); testWidgets('displays sort button', (tester) async { await tester.pumpWidget(buildAnnotations()); - expect(find.byIcon(Icons.sort), findsOneWidget); }); testWidgets('renders AnnotationCard for each annotation', (tester) async { await tester.pumpWidget(buildAnnotations()); - expect(find.byType(AnnotationCard), findsNWidgets(4)); }); testWidgets('shows empty state when annotations list is empty', (tester) async { await tester.pumpWidget(buildAnnotations(annotations: [])); - expect(find.text('No annotations yet'), findsOneWidget); expect(find.byIcon(Icons.highlight_outlined), findsOneWidget); }); @@ -95,19 +91,15 @@ void main() { group('search', () { testWidgets('filters by highlight text', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.enterText(find.byType(TextField), 'Knowledge'); await tester.pump(); - expect(find.byType(AnnotationCard), findsOneWidget); }); testWidgets('filters by note content', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.enterText(find.byType(TextField), 'foundational'); await tester.pump(); - expect(find.byType(AnnotationCard), findsOneWidget); }); @@ -117,26 +109,21 @@ void main() { // BookLocation.displayLocation for ann-1: "Chapter 1, Page 10" await tester.enterText(find.byType(TextField), 'Page 55'); await tester.pump(); - expect(find.byType(AnnotationCard), findsOneWidget); }); testWidgets('shows no results when no matches', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.enterText(find.byType(TextField), 'zzzznonexistent'); await tester.pump(); - expect(find.text('No annotations found'), findsOneWidget); }); testWidgets('clearing search restores all', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.enterText(find.byType(TextField), 'Knowledge'); await tester.pump(); expect(find.byType(AnnotationCard), findsOneWidget); - await tester.enterText(find.byType(TextField), ''); await tester.pump(); expect(find.byType(AnnotationCard), findsNWidgets(4)); @@ -146,10 +133,8 @@ void main() { group('sorting', () { testWidgets('sort menu shows 4 options', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); - expect(find.text('Newest first'), findsOneWidget); expect(find.text('Oldest first'), findsOneWidget); expect(find.text('By position'), findsOneWidget); @@ -167,7 +152,6 @@ void main() { testWidgets('selecting by position reorders by page number', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); await tester.tap(find.text('By position')); @@ -181,7 +165,6 @@ void main() { testWidgets('selecting by color reorders by color enum index', (tester) async { await tester.pumpWidget(buildAnnotations()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); await tester.tap(find.text('By color')); @@ -198,20 +181,16 @@ void main() { testWidgets('tap calls onAnnotationTap', (tester) async { Annotation? tappedAnnotation; await tester.pumpWidget(buildAnnotations(onAnnotationTap: (a) => tappedAnnotation = a)); - await tester.tap(find.byType(AnnotationCard).first); await tester.pump(); - expect(tappedAnnotation, isNotNull); }); testWidgets('long press calls onAnnotationActions', (tester) async { Annotation? actionAnnotation; await tester.pumpWidget(buildAnnotations(onAnnotationActions: (a) => actionAnnotation = a)); - await tester.longPress(find.byType(AnnotationCard).first); await tester.pump(); - expect(actionAnnotation, isNotNull); }); }); @@ -219,13 +198,11 @@ void main() { group('responsive', () { testWidgets('desktop layout shows action menu on items', (tester) async { await tester.pumpWidget(buildAnnotations(screenSize: const Size(1200, 800))); - expect(find.byIcon(Icons.more_vert), findsAtLeastNWidgets(1)); }); testWidgets('mobile layout hides action menu on items', (tester) async { await tester.pumpWidget(buildAnnotations(screenSize: const Size(400, 800))); - expect(find.byIcon(Icons.more_vert), findsNothing); }); }); diff --git a/app/test/widgets/book/book_bookmarks_test.dart b/app/test/widgets/book/book_bookmarks_test.dart index f115c9f2..7d562670 100644 --- a/app/test/widgets/book/book_bookmarks_test.dart +++ b/app/test/widgets/book/book_bookmarks_test.dart @@ -11,6 +11,7 @@ void main() { setUp(() { final now = DateTime.now(); + testBookmarks = [ Bookmark( id: 'bm-1', @@ -62,25 +63,21 @@ void main() { group('rendering', () { testWidgets('displays search field with hint text', (tester) async { await tester.pumpWidget(buildBookmarks()); - expect(find.text('Search bookmarks...'), findsOneWidget); }); testWidgets('displays sort button', (tester) async { await tester.pumpWidget(buildBookmarks()); - expect(find.byIcon(Icons.sort), findsOneWidget); }); testWidgets('renders BookmarkListItem for each bookmark', (tester) async { await tester.pumpWidget(buildBookmarks()); - expect(find.byType(BookmarkListItem), findsNWidgets(3)); }); testWidgets('shows empty state when bookmarks list is empty', (tester) async { await tester.pumpWidget(buildBookmarks(bookmarks: [])); - expect(find.text('No bookmarks yet'), findsOneWidget); expect(find.byIcon(Icons.bookmark_outline), findsOneWidget); }); @@ -89,29 +86,23 @@ void main() { group('search', () { testWidgets('filters bookmarks by location text', (tester) async { await tester.pumpWidget(buildBookmarks()); - await tester.enterText(find.byType(TextField), 'Beginning'); await tester.pump(); - expect(find.byType(BookmarkListItem), findsOneWidget); expect(find.text('The Beginning, Page 50'), findsOneWidget); }); testWidgets('filters bookmarks by note text', (tester) async { await tester.pumpWidget(buildBookmarks()); - await tester.enterText(find.byType(TextField), 'philosophy'); await tester.pump(); - expect(find.byType(BookmarkListItem), findsOneWidget); }); testWidgets('shows no results when search has no matches', (tester) async { await tester.pumpWidget(buildBookmarks()); - await tester.enterText(find.byType(TextField), 'zzzznonexistent'); await tester.pump(); - expect(find.text('No bookmarks found'), findsOneWidget); }); @@ -133,10 +124,8 @@ void main() { group('sorting', () { testWidgets('tapping sort button opens popup menu with 3 options', (tester) async { await tester.pumpWidget(buildBookmarks()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); - expect(find.text('Newest first'), findsOneWidget); expect(find.text('Oldest first'), findsOneWidget); expect(find.text('By position'), findsOneWidget); @@ -144,7 +133,6 @@ void main() { testWidgets('newest first is selected by default', (tester) async { await tester.pumpWidget(buildBookmarks()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); @@ -174,7 +162,6 @@ void main() { testWidgets('selecting by position reorders list', (tester) async { await tester.pumpWidget(buildBookmarks()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); await tester.tap(find.text('By position')); @@ -191,10 +178,8 @@ void main() { testWidgets('long press on bookmark calls onBookmarkActions', (tester) async { Bookmark? actionBookmark; await tester.pumpWidget(buildBookmarks(onBookmarkActions: (b) => actionBookmark = b)); - await tester.longPress(find.byType(BookmarkListItem).first); await tester.pump(); - expect(actionBookmark, isNotNull); expect(actionBookmark!.id, 'bm-3'); // newest first by default }); @@ -203,13 +188,11 @@ void main() { group('responsive', () { testWidgets('desktop layout shows action menu on items', (tester) async { await tester.pumpWidget(buildBookmarks(screenSize: const Size(1200, 800))); - expect(find.byIcon(Icons.more_vert), findsNWidgets(3)); }); testWidgets('mobile layout hides action menu on items', (tester) async { await tester.pumpWidget(buildBookmarks(screenSize: const Size(400, 800))); - expect(find.byIcon(Icons.more_vert), findsNothing); }); }); diff --git a/app/test/widgets/book/book_notes_test.dart b/app/test/widgets/book/book_notes_test.dart index 7d75cd0a..20133560 100644 --- a/app/test/widgets/book/book_notes_test.dart +++ b/app/test/widgets/book/book_notes_test.dart @@ -62,25 +62,21 @@ void main() { group('rendering', () { testWidgets('displays search field with hint text', (tester) async { await tester.pumpWidget(buildNotes()); - expect(find.text('Search notes...'), findsOneWidget); }); testWidgets('displays sort button', (tester) async { await tester.pumpWidget(buildNotes()); - expect(find.byIcon(Icons.sort), findsOneWidget); }); testWidgets('renders NoteCard for each note', (tester) async { await tester.pumpWidget(buildNotes()); - expect(find.byType(NoteCard), findsNWidgets(3)); }); testWidgets('shows empty state when notes list is empty', (tester) async { await tester.pumpWidget(buildNotes(notes: [])); - expect(find.text('No notes yet'), findsOneWidget); expect(find.byIcon(Icons.note_outlined), findsOneWidget); }); @@ -89,47 +85,37 @@ void main() { group('search', () { testWidgets('filters by title', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.enterText(find.byType(TextField), 'Architecture'); await tester.pump(); - expect(find.byType(NoteCard), findsOneWidget); }); testWidgets('filters by content', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.enterText(find.byType(TextField), 'Beautiful'); await tester.pump(); - expect(find.byType(NoteCard), findsOneWidget); }); testWidgets('filters by tag', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.enterText(find.byType(TextField), 'philosophy'); await tester.pump(); - expect(find.byType(NoteCard), findsOneWidget); }); testWidgets('shows no results when no matches', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.enterText(find.byType(TextField), 'zzzznonexistent'); await tester.pump(); - expect(find.text('No notes found'), findsOneWidget); }); testWidgets('clearing search restores all notes', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.enterText(find.byType(TextField), 'Architecture'); await tester.pump(); expect(find.byType(NoteCard), findsOneWidget); - await tester.enterText(find.byType(TextField), ''); await tester.pump(); expect(find.byType(NoteCard), findsNWidgets(3)); @@ -139,10 +125,8 @@ void main() { group('sorting', () { testWidgets('sort menu shows 3 options', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); - expect(find.text('Newest first'), findsOneWidget); expect(find.text('Oldest first'), findsOneWidget); expect(find.text('By title'), findsOneWidget); @@ -159,7 +143,6 @@ void main() { testWidgets('selecting by title reorders alphabetically', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); await tester.tap(find.text('By title')); @@ -173,7 +156,6 @@ void main() { testWidgets('selecting oldest first reorders by date', (tester) async { await tester.pumpWidget(buildNotes()); - await tester.tap(find.byIcon(Icons.sort)); await tester.pumpAndSettle(); await tester.tap(find.text('Oldest first')); @@ -190,20 +172,16 @@ void main() { testWidgets('tap calls onNoteTap', (tester) async { Note? tappedNote; await tester.pumpWidget(buildNotes(onNoteTap: (n) => tappedNote = n)); - await tester.tap(find.byType(NoteCard).first); await tester.pump(); - expect(tappedNote, isNotNull); }); testWidgets('long press calls onNoteActions', (tester) async { Note? actionNote; await tester.pumpWidget(buildNotes(onNoteActions: (n) => actionNote = n)); - await tester.longPress(find.byType(NoteCard).first); await tester.pump(); - expect(actionNote, isNotNull); }); }); @@ -211,25 +189,21 @@ void main() { group('responsive', () { testWidgets('desktop shows add note button in header', (tester) async { await tester.pumpWidget(buildNotes(screenSize: const Size(1200, 800))); - expect(find.text('Add note'), findsOneWidget); }); testWidgets('mobile does not show add note button in header', (tester) async { await tester.pumpWidget(buildNotes(screenSize: const Size(400, 800))); - expect(find.text('Add note'), findsNothing); }); testWidgets('desktop layout shows action menu on items', (tester) async { await tester.pumpWidget(buildNotes(screenSize: const Size(1200, 800))); - expect(find.byIcon(Icons.more_vert), findsNWidgets(3)); }); testWidgets('mobile layout hides action menu on items', (tester) async { await tester.pumpWidget(buildNotes(screenSize: const Size(400, 800))); - expect(find.byIcon(Icons.more_vert), findsNothing); }); }); diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index b8271729..1ab14690 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -90,10 +90,8 @@ void main() { expect(find.byIcon(Icons.auto_stories_rounded), findsOneWidget); expect(find.text('Loading…'), findsOneWidget); expect(find.byKey(const Key('fallback-placeholder')), findsNothing); - coverBytes.complete(Uint8List.fromList(pngBytes)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('cover-image-loading')), findsNothing); expect(find.byType(Image), findsOneWidget); }); @@ -131,12 +129,10 @@ void main() { await tester.pumpWidget(harness.wrap(Align(child: cover()))); await _waitForDecodedCover(tester); expect(importService.cachedCoverReads, 1); - await tester.pumpWidget(const SizedBox()); await tester.pump(); await tester.pumpWidget(harness.wrap(Center(child: cover()))); await tester.pump(); - expect(importService.cachedCoverReads, 1); expect(find.byKey(const Key('placeholder')), findsNothing); expect(find.byType(Image), findsOneWidget); @@ -147,6 +143,7 @@ void main() { Uint8List.fromList(pngBytes), pendingCoverBytes: Uint8List.fromList(pngBytes), ); + final harness = await _buildProviderHarness(importService: importService); await tester.pumpWidget( @@ -158,8 +155,8 @@ void main() { ), ), ); - await _waitForDecodedCover(tester); + await _waitForDecodedCover(tester); expect(importService.pendingCoverReads, 1); expect(importService.cachedCoverReads, 0); expect(find.byType(Image), findsOneWidget); @@ -168,6 +165,7 @@ void main() { testWidgets('provider-backed pending cover reuses its decoded image across pages', (tester) async { final harness = await _buildProviderHarness(); var pendingReads = 0; + Future loadPending(MediaStorageScope scope, String bookId) async { pendingReads++; return Uint8List.fromList(pngBytes); @@ -184,12 +182,10 @@ void main() { await tester.pumpWidget(harness.wrap(Align(child: cover()))); await _waitForDecodedCover(tester); expect(pendingReads, 1); - await tester.pumpWidget(const SizedBox()); await tester.pump(); await tester.pumpWidget(harness.wrap(Center(child: cover()))); await tester.pump(); - expect(pendingReads, 1); expect(find.byKey(const Key('placeholder')), findsNothing); expect(find.byType(Image), findsOneWidget); @@ -198,6 +194,7 @@ void main() { testWidgets('provider-backed guest cover reuses its decoded image across pages', (tester) async { final harness = await _buildProviderHarness(offline: true); var guestReads = 0; + Future loadGuest(String bookId) async { guestReads++; return Uint8List.fromList(pngBytes); @@ -214,12 +211,10 @@ void main() { await tester.pumpWidget(harness.wrap(Align(child: cover()))); await _waitForDecodedCover(tester); expect(guestReads, 1); - await tester.pumpWidget(const SizedBox()); await tester.pump(); await tester.pumpWidget(harness.wrap(Center(child: cover()))); await tester.pump(); - expect(guestReads, 1); expect(find.byKey(const Key('placeholder')), findsNothing); expect(find.byType(Image), findsOneWidget); @@ -227,6 +222,7 @@ void main() { testWidgets('private cover renders lazily loaded bytes', (tester) async { var loads = 0; + await tester.pumpWidget( MaterialApp( home: CoverImage( @@ -239,8 +235,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byType(Image), findsOneWidget); expect(find.byKey(const Key('placeholder')), findsNothing); expect(loads, 1); @@ -261,8 +257,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byType(Image), findsOneWidget); expect(find.byKey(const Key('placeholder')), findsNothing); expect(loadedBookIds, ['book-1']); @@ -289,14 +285,15 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(privateLoads, 1); expect(localLoads, 0); }); testWidgets('local cover keeps one load future across rebuilds', (tester) async { var loads = 0; + Future loader(String _) async { loads++; return Uint8List.fromList(pngBytes); @@ -316,12 +313,12 @@ void main() { await tester.pumpAndSettle(); await tester.pumpWidget(build('book-1')); await tester.pumpAndSettle(); - expect(loads, 1); }); testWidgets('local cover reloads when book id changes', (tester) async { final loadedBookIds = []; + Future loader(String bookId) async { loadedBookIds.add(bookId); return Uint8List.fromList(pngBytes); @@ -341,7 +338,6 @@ void main() { await tester.pumpAndSettle(); await tester.pumpWidget(build('book-2')); await tester.pumpAndSettle(); - expect(loadedBookIds, ['book-1', 'book-2']); }); @@ -365,7 +361,6 @@ void main() { await tester.pumpAndSettle(); await tester.pumpWidget(build(secondLoader)); await tester.pumpAndSettle(); - final image = tester.widget(find.byType(Image)); expect(identical((image.image as MemoryImage).bytes, secondBytes), isTrue); }); @@ -390,7 +385,6 @@ void main() { await tester.pumpAndSettle(); await tester.pumpWidget(build(secondLoader)); await tester.pumpAndSettle(); - final image = tester.widget(find.byType(Image)); expect(identical((image.image as MemoryImage).bytes, secondBytes), isTrue); }); @@ -422,8 +416,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(pendingLoads, 1); expect(guestLoads, 0); refreshGate.complete(harness.repository.tokens); @@ -453,9 +447,11 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(guestLoads, 1); expect(pendingLoads, 0); + _expectLocalCoverKey( tester, scopeKey: MediaStorageScope.localGuest.persistenceKey, @@ -468,6 +464,7 @@ void main() { await tester.pumpAndSettle(); expect(guestLoads, 1); expect(pendingLoads, 1); + _expectLocalCoverKey( tester, scopeKey: '${SyncSettingsProvider.officialServerId}--user-1', @@ -479,6 +476,7 @@ void main() { await tester.pumpAndSettle(); expect(guestLoads, 1); expect(pendingLoads, 1); + _expectLocalCoverKey( tester, scopeKey: MediaStorageScope.localGuest.persistenceKey, @@ -506,13 +504,13 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); harness.syncSettings.setCustomServerUrls(apiUrl: 'https://custom.test', powerSyncUrl: 'https://sync.custom.test'); await tester.pumpAndSettle(); - expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId, startsWith('custom-')]); final customScope = scopes.last; + _expectLocalCoverKey( tester, scopeKey: customScope.persistenceKey, @@ -527,8 +525,8 @@ void main() { final officialBytes = Uint8List.fromList(pngBytes); final customBytes = Uint8List.fromList(pngBytes); final scopes = []; - Future localLoader(String _) async => injectedBytes; + Future pendingLoader(MediaStorageScope scope, String _) async { scopes.add(scope); return scope.profileKey == SyncSettingsProvider.officialServerId ? officialBytes : customBytes; @@ -550,10 +548,10 @@ void main() { await tester.pumpAndSettle(); expect(scopes, isEmpty); expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, injectedBytes), isTrue); - await tester.pumpWidget(build(injected: false)); await tester.pumpAndSettle(); expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId]); + _expectLocalCoverKey( tester, scopeKey: scopes.single.persistenceKey, @@ -563,8 +561,8 @@ void main() { harness.syncSettings.setCustomServerUrls(apiUrl: 'https://custom.test', powerSyncUrl: 'https://sync.custom.test'); await tester.pumpAndSettle(); - expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId, startsWith('custom-')]); + _expectLocalCoverKey( tester, scopeKey: scopes.last.persistenceKey, @@ -583,8 +581,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byKey(const Key('placeholder')), findsOneWidget); expect(find.byType(Image), findsNothing); }); @@ -635,6 +633,7 @@ void main() { testWidgets('private cover keeps one load future across rebuilds', (tester) async { var loads = 0; + Future loader(String _) async { loads++; return Uint8List.fromList(pngBytes); @@ -654,7 +653,6 @@ void main() { await tester.pumpAndSettle(); await tester.pumpWidget(build()); await tester.pumpAndSettle(); - expect(loads, 1); }); @@ -668,8 +666,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byKey(const Key('placeholder')), findsOneWidget); expect(find.byType(Image), findsNothing); }); @@ -684,8 +682,8 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.byKey(const Key('placeholder')), findsOneWidget); expect(find.byType(Image), findsNothing); }); @@ -693,22 +691,30 @@ void main() { Future _waitForDecodedCover(WidgetTester tester) async { final image = tester.widget(find.byType(Image)); + await tester.runAsync(() async { final firstFrame = Completer(); final stream = image.image.resolve(ImageConfiguration.empty); late ImageStreamListener listener; + listener = ImageStreamListener( (_, _) { - if (!firstFrame.isCompleted) firstFrame.complete(); + if (!firstFrame.isCompleted) { + firstFrame.complete(); + } }, onError: (Object error, StackTrace? stackTrace) { - if (!firstFrame.isCompleted) firstFrame.completeError(error, stackTrace); + if (!firstFrame.isCompleted) { + firstFrame.completeError(error, stackTrace); + } }, ); + stream.addListener(listener); await firstFrame.future; stream.removeListener(listener); }); + await tester.pump(); } @@ -756,6 +762,7 @@ Future<_ProviderHarness> _buildProviderHarness({ MediaCacheService? cacheService, }) async { final prefs = await SharedPreferences.getInstance(); + final tokens = AuthTokens( accessToken: 'access-token', refreshToken: 'refresh-token', @@ -771,13 +778,16 @@ Future<_ProviderHarness> _buildProviderHarness({ lastLoginAt: null, ), ); + final repository = _GatedAuthRepository(tokens); final authProvider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + if (offline) { authProvider.setOfflineMode(true); } else { await authProvider.bootstrap(); } + final syncSettings = SyncSettingsProvider( prefs, officialConfig: PapyrusApiConfig( @@ -785,6 +795,7 @@ Future<_ProviderHarness> _buildProviderHarness({ powerSyncServiceUri: Uri.parse('https://sync.test'), ), ); + return _ProviderHarness( repository: repository, authProvider: authProvider, diff --git a/app/test/widgets/book_details/annotation_card_test.dart b/app/test/widgets/book_details/annotation_card_test.dart index 8961b6da..afab88ef 100644 --- a/app/test/widgets/book_details/annotation_card_test.dart +++ b/app/test/widgets/book_details/annotation_card_test.dart @@ -50,31 +50,26 @@ void main() { group('rendering', () { testWidgets('displays highlight text in italic with quotes', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text('"The quick brown fox jumps over the lazy dog."'), findsOneWidget); }); testWidgets('displays location', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text('Ch. 3, p. 45'), findsOneWidget); }); testWidgets('displays date', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text('Jun 15, 2025'), findsOneWidget); }); testWidgets('shows note section when annotation has a note', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text('A classic pangram used in typography.'), findsOneWidget); }); testWidgets('hides note section when no note', (tester) async { await tester.pumpWidget(buildCard(annotation: annotationWithoutNote)); - expect(find.text('A classic pangram used in typography.'), findsNothing); }); @@ -86,30 +81,30 @@ void main() { if (widget is Container && widget.decoration is BoxDecoration) { final decoration = widget.decoration as BoxDecoration; final border = decoration.border; + if (border is Border && border.left.width == 4) { return border.left.color == HighlightColor.blue.accentColor; } } + return false; }); + expect(container, findsOneWidget); }); testWidgets('shows action menu when showActionMenu is true', (tester) async { await tester.pumpWidget(buildCard(showActionMenu: true)); - expect(find.byIcon(Icons.more_vert), findsOneWidget); }); testWidgets('hides action menu when showActionMenu is false', (tester) async { await tester.pumpWidget(buildCard(showActionMenu: false)); - expect(find.byIcon(Icons.more_vert), findsNothing); }); testWidgets('displays location with chapter title when present', (tester) async { await tester.pumpWidget(buildCard(annotation: annotationWithoutNote)); - expect(find.text('Ch. 1, p. 12'), findsOneWidget); }); }); @@ -118,30 +113,24 @@ void main() { testWidgets('tap calls onTap', (tester) async { var tapped = false; await tester.pumpWidget(buildCard(onTap: () => tapped = true)); - await tester.tap(find.byType(AnnotationCard)); await tester.pump(); - expect(tapped, isTrue); }); testWidgets('long press calls onLongPress', (tester) async { var longPressed = false; await tester.pumpWidget(buildCard(onLongPress: () => longPressed = true)); - await tester.longPress(find.byType(AnnotationCard)); await tester.pump(); - expect(longPressed, isTrue); }); testWidgets('action menu button calls onLongPress', (tester) async { var pressed = false; await tester.pumpWidget(buildCard(onLongPress: () => pressed = true)); - await tester.tap(find.byIcon(Icons.more_vert)); await tester.pump(); - expect(pressed, isTrue); }); }); diff --git a/app/test/widgets/book_details/annotation_dialog_test.dart b/app/test/widgets/book_details/annotation_dialog_test.dart index bdfa869d..e075d7ac 100644 --- a/app/test/widgets/book_details/annotation_dialog_test.dart +++ b/app/test/widgets/book_details/annotation_dialog_test.dart @@ -19,7 +19,6 @@ void main() { await tester.tap(find.text('Add annotation')); await tester.pumpAndSettle(); - expect(find.byType(DraggableScrollableSheet), findsNothing); expect(find.text('New annotation'), findsOneWidget); await tester.drag(find.byType(ListView), const Offset(0, -300)); diff --git a/app/test/widgets/book_details/book_action_buttons_test.dart b/app/test/widgets/book_details/book_action_buttons_test.dart index 9bcfa7a3..2b26f42a 100644 --- a/app/test/widgets/book_details/book_action_buttons_test.dart +++ b/app/test/widgets/book_details/book_action_buttons_test.dart @@ -22,6 +22,7 @@ void main() { ), ), ); + expect(find.text('Update progress'), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -57,14 +58,12 @@ void main() { group('digital book', () { testWidgets('shows Continue button when book has progress', (tester) async { await tester.pumpWidget(buildWidget(book: buildTestBook(currentPosition: 0.5, fileFormat: BookFormat.epub))); - expect(find.text('Continue'), findsOneWidget); expect(find.byIcon(Icons.play_arrow), findsOneWidget); }); testWidgets('shows Read button when book has no progress (mobile)', (tester) async { await tester.pumpWidget(buildWidget(book: buildTestBook(currentPosition: 0.0, fileFormat: BookFormat.epub))); - expect(find.text('Read'), findsOneWidget); expect(find.byIcon(Icons.menu_book), findsOneWidget); }); @@ -79,6 +78,7 @@ void main() { testWidgets('calls onContinueReading when tapped', (tester) async { var called = false; + await tester.pumpWidget( buildWidget( book: buildTestBook(currentPosition: 0.5, fileFormat: BookFormat.epub), @@ -119,13 +119,13 @@ void main() { group('physical book', () { testWidgets('shows Update progress button', (tester) async { await tester.pumpWidget(buildWidget(book: buildTestBook(isPhysical: true))); - expect(find.text('Update progress'), findsOneWidget); expect(find.byIcon(Icons.edit_note), findsOneWidget); }); testWidgets('calls onUpdateProgress when tapped', (tester) async { var called = false; + await tester.pumpWidget( buildWidget(book: buildTestBook(isPhysical: true), onUpdateProgress: () => called = true), ); @@ -138,18 +138,17 @@ void main() { group('favorite button', () { testWidgets('shows outlined heart when not favorite', (tester) async { await tester.pumpWidget(buildWidget(book: buildTestBook(isFavorite: false, fileFormat: BookFormat.epub))); - expect(find.byIcon(Icons.favorite_border), findsOneWidget); }); testWidgets('shows filled heart when favorite', (tester) async { await tester.pumpWidget(buildWidget(book: buildTestBook(isFavorite: true, fileFormat: BookFormat.epub))); - expect(find.byIcon(Icons.favorite), findsOneWidget); }); testWidgets('calls onToggleFavorite when tapped', (tester) async { var called = false; + await tester.pumpWidget( buildWidget( book: buildTestBook(fileFormat: BookFormat.epub), @@ -162,6 +161,7 @@ void main() { of: find.byIcon(Icons.favorite_border), matching: find.byType(OutlinedButton), ); + await tester.tap(favoriteButton); expect(called, true); }); @@ -170,12 +170,12 @@ void main() { group('edit button', () { testWidgets('shows edit icon', (tester) async { await tester.pumpWidget(buildWidget(book: buildTestBook(fileFormat: BookFormat.epub))); - expect(find.byIcon(Icons.edit_outlined), findsOneWidget); }); testWidgets('calls onEdit when tapped', (tester) async { var called = false; + await tester.pumpWidget( buildWidget( book: buildTestBook(fileFormat: BookFormat.epub), diff --git a/app/test/widgets/book_details/book_progress_bar_test.dart b/app/test/widgets/book_details/book_progress_bar_test.dart index 9056df22..61bb392c 100644 --- a/app/test/widgets/book_details/book_progress_bar_test.dart +++ b/app/test/widgets/book_details/book_progress_bar_test.dart @@ -27,25 +27,21 @@ void main() { group('rendering', () { testWidgets('displays a LinearProgressIndicator', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.byType(LinearProgressIndicator), findsOneWidget); }); testWidgets('shows percentage label by default', (tester) async { await tester.pumpWidget(buildWidget(progress: 0.42)); - expect(find.text('42%'), findsOneWidget); }); testWidgets('shows page numbers when provided', (tester) async { await tester.pumpWidget(buildWidget(progress: 0.5, currentPage: 150, totalPages: 300)); - expect(find.text('150 / 300 (50%)'), findsOneWidget); }); testWidgets('hides label when showLabel is false', (tester) async { await tester.pumpWidget(buildWidget(progress: 0.5, showLabel: false)); - expect(find.text('50%'), findsNothing); }); }); @@ -71,16 +67,13 @@ void main() { testWidgets('wraps in GestureDetector when onTap provided', (tester) async { var called = false; await tester.pumpWidget(buildWidget(onTap: () => called = true)); - expect(find.byType(GestureDetector), findsOneWidget); - await tester.tap(find.byType(GestureDetector)); expect(called, true); }); testWidgets('does not wrap in GestureDetector when onTap is null', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.byType(GestureDetector), findsNothing); }); }); diff --git a/app/test/widgets/book_details/bookmark_dialog_test.dart b/app/test/widgets/book_details/bookmark_dialog_test.dart index d622e0df..dd853694 100644 --- a/app/test/widgets/book_details/bookmark_dialog_test.dart +++ b/app/test/widgets/book_details/bookmark_dialog_test.dart @@ -33,6 +33,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); } @@ -44,6 +45,7 @@ void main() { ) async { final write = Completer(); var calls = 0; + Future save() { calls++; return write.future; @@ -61,14 +63,20 @@ void main() { DeleteBookmarkDialog.show(context, bookmark: bookmark, bookTitle: 'Physical Book', onDelete: save); } }); - if (kind == 'create') await tester.enterText(find.byType(TextFormField).first, '30'); + + if (kind == 'create') { + await tester.enterText(find.byType(TextFormField).first, '30'); + } + final action = kind == 'color' ? colorChoices().first : find.widgetWithText(FilledButton, kind == 'delete' ? 'Delete' : 'Save'); + await tester.tap(action); await tester.pump(); expect(calls, 1); expect(action, findsOneWidget); + if (kind == 'color') { expect(tester.widget(action).onTap, isNull); } else { @@ -80,14 +88,18 @@ void main() { } else { write.complete(); } + await tester.pumpAndSettle(); + if (kind == 'color' && !fails) { expect(colorChoices(), findsNothing); } else { expect(action, fails ? findsOneWidget : findsNothing); } + if (fails) { expect(find.text('Could not save changes. Please try again.'), findsOneWidget); + if (kind == 'color') { expect(tester.widget(action).onTap, isNotNull); } else { @@ -102,6 +114,7 @@ void main() { tester, ) async { Bookmark? saved; + await open( tester, (context) => BookmarkDialog.show( @@ -113,10 +126,12 @@ void main() { }, ), ); + final fields = find.byType(TextFormField); await tester.enterText(fields.at(0), '30'); await tester.enterText(fields.at(1), ' First chapter '); await tester.enterText(fields.at(2), ' Remember this '); + final blue = find.byWidgetPredicate( (widget) => widget is GestureDetector && @@ -124,10 +139,10 @@ void main() { (widget.child as Container).decoration is BoxDecoration && ((widget.child as Container).decoration as BoxDecoration).color == const Color(0xFF2196F3), ); + await tester.tap(blue); await tester.tap(find.widgetWithText(FilledButton, 'Save')); await tester.pumpAndSettle(); - expect(saved, isNotNull); expect(saved!.id, matches(RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'))); expect(saved!.bookId, 'physical-book'); @@ -141,6 +156,7 @@ void main() { testWidgets('physical bookmark without a total page count retains its entered page', (tester) async { Bookmark? saved; + await open( tester, (context) => BookmarkDialog.show( @@ -151,6 +167,7 @@ void main() { }, ), ); + await tester.enterText(find.byType(TextFormField).first, '47'); await tester.tap(find.widgetWithText(FilledButton, 'Save')); await tester.pumpAndSettle(); diff --git a/app/test/widgets/book_details/bookmark_list_item_test.dart b/app/test/widgets/book_details/bookmark_list_item_test.dart index 0a41c97b..50afee1b 100644 --- a/app/test/widgets/book_details/bookmark_list_item_test.dart +++ b/app/test/widgets/book_details/bookmark_list_item_test.dart @@ -47,25 +47,21 @@ void main() { group('rendering', () { testWidgets('displays location text', (tester) async { await tester.pumpWidget(buildItem()); - expect(find.text('Chapter 3, Page 42'), findsOneWidget); }); testWidgets('displays relative date', (tester) async { await tester.pumpWidget(buildItem()); - expect(find.text('2 hours ago'), findsOneWidget); }); testWidgets('shows note text when bookmark has a note', (tester) async { await tester.pumpWidget(buildItem()); - expect(find.text('This is an important passage to remember.'), findsOneWidget); }); testWidgets('hides note section when no note', (tester) async { await tester.pumpWidget(buildItem(bookmark: bookmarkWithoutNote)); - expect(find.byIcon(Icons.note_outlined), findsNothing); }); @@ -80,24 +76,22 @@ void main() { (widget.decoration as BoxDecoration).shape == BoxShape.circle && widget.constraints?.maxWidth == 8, ); + expect(dot, findsOneWidget); }); testWidgets('shows action menu when showActionMenu is true', (tester) async { await tester.pumpWidget(buildItem(showActionMenu: true)); - expect(find.byIcon(Icons.more_vert), findsOneWidget); }); testWidgets('hides action menu when showActionMenu is false', (tester) async { await tester.pumpWidget(buildItem(showActionMenu: false)); - expect(find.byIcon(Icons.more_vert), findsNothing); }); testWidgets('displays page-only location when no chapter', (tester) async { await tester.pumpWidget(buildItem(bookmark: bookmarkWithoutNote)); - expect(find.text('Page 100'), findsOneWidget); }); }); @@ -106,20 +100,16 @@ void main() { testWidgets('long press calls onLongPress', (tester) async { var longPressed = false; await tester.pumpWidget(buildItem(onLongPress: () => longPressed = true)); - await tester.longPress(find.byType(BookmarkListItem)); await tester.pump(); - expect(longPressed, isTrue); }); testWidgets('action menu button calls onLongPress', (tester) async { var pressed = false; await tester.pumpWidget(buildItem(onLongPress: () => pressed = true)); - await tester.tap(find.byIcon(Icons.more_vert)); await tester.pump(); - expect(pressed, isTrue); }); }); diff --git a/app/test/widgets/book_details/empty_annotations_state_test.dart b/app/test/widgets/book_details/empty_annotations_state_test.dart index aee3b65d..7e1a3e09 100644 --- a/app/test/widgets/book_details/empty_annotations_state_test.dart +++ b/app/test/widgets/book_details/empty_annotations_state_test.dart @@ -15,13 +15,11 @@ void main() { group('rendering', () { testWidgets('displays highlight icon', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.byIcon(Icons.highlight_outlined), findsOneWidget); }); testWidgets('displays title text', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.text('No annotations yet'), findsOneWidget); }); }); @@ -29,13 +27,11 @@ void main() { group('digital book', () { testWidgets('shows digital book description', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.text('Highlight text while reading to create annotations.'), findsOneWidget); }); testWidgets('does not show add annotation button', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.text('Add annotation'), findsNothing); }); }); @@ -43,13 +39,11 @@ void main() { group('physical book', () { testWidgets('shows physical book description', (tester) async { await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text("Add passages you've highlighted or underlined in your book."), findsOneWidget); }); testWidgets('shows add annotation button', (tester) async { await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text('Add annotation'), findsOneWidget); expect(find.byIcon(Icons.add), findsOneWidget); }); @@ -57,7 +51,6 @@ void main() { testWidgets('add button calls onAddAnnotation', (tester) async { var called = false; await tester.pumpWidget(buildWidget(isPhysical: true, onAddAnnotation: () => called = true)); - await tester.tap(find.text('Add annotation')); expect(called, true); }); diff --git a/app/test/widgets/book_details/empty_bookmarks_state_test.dart b/app/test/widgets/book_details/empty_bookmarks_state_test.dart index 9638ac67..e814e4db 100644 --- a/app/test/widgets/book_details/empty_bookmarks_state_test.dart +++ b/app/test/widgets/book_details/empty_bookmarks_state_test.dart @@ -15,13 +15,11 @@ void main() { group('rendering', () { testWidgets('displays bookmark icon', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.byIcon(Icons.bookmark_outline), findsOneWidget); }); testWidgets('displays title text', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.text('No bookmarks yet'), findsOneWidget); }); }); @@ -29,13 +27,11 @@ void main() { group('digital book', () { testWidgets('shows digital book description', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.text('Bookmarks you create while reading will appear here.'), findsOneWidget); }); testWidgets('does not show add bookmark button', (tester) async { await tester.pumpWidget(buildWidget()); - expect(find.text('Add bookmark'), findsNothing); }); }); @@ -43,13 +39,11 @@ void main() { group('physical book', () { testWidgets('shows physical book description', (tester) async { await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text('Save pages you want to return to later.'), findsOneWidget); }); testWidgets('shows add bookmark button', (tester) async { await tester.pumpWidget(buildWidget(isPhysical: true)); - expect(find.text('Add bookmark'), findsOneWidget); expect(find.byIcon(Icons.add), findsOneWidget); }); @@ -57,7 +51,6 @@ void main() { testWidgets('add button calls onAddBookmark', (tester) async { var called = false; await tester.pumpWidget(buildWidget(isPhysical: true, onAddBookmark: () => called = true)); - await tester.tap(find.text('Add bookmark')); expect(called, true); }); diff --git a/app/test/widgets/book_details/note_card_test.dart b/app/test/widgets/book_details/note_card_test.dart index 06aa2fe1..7da9dc16 100644 --- a/app/test/widgets/book_details/note_card_test.dart +++ b/app/test/widgets/book_details/note_card_test.dart @@ -49,63 +49,53 @@ void main() { group('rendering', () { testWidgets('displays note title', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text('Key Takeaways'), findsOneWidget); }); testWidgets('displays content preview', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text(noteWithLocation.preview), findsOneWidget); }); testWidgets('displays tags as chips', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.widgetWithText(Chip, 'summary'), findsOneWidget); expect(find.widgetWithText(Chip, 'important'), findsOneWidget); }); testWidgets('displays location with icon when present', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.byIcon(Icons.location_on_outlined), findsOneWidget); expect(find.text('Ch. 5, p. 120'), findsOneWidget); }); testWidgets('hides location when not set', (tester) async { await tester.pumpWidget(buildCard(note: noteWithoutLocation)); - expect(find.byIcon(Icons.location_on_outlined), findsNothing); }); testWidgets('shows action menu when showActionMenu is true', (tester) async { await tester.pumpWidget(buildCard(showActionMenu: true)); - expect(find.byIcon(Icons.more_vert), findsOneWidget); }); testWidgets('hides action menu when showActionMenu is false', (tester) async { await tester.pumpWidget(buildCard(showActionMenu: false)); - expect(find.byIcon(Icons.more_vert), findsNothing); }); testWidgets('displays created date label', (tester) async { await tester.pumpWidget(buildCard()); - expect(find.text('Created Apr 20, 2025'), findsOneWidget); }); testWidgets('displays edited date label when updated', (tester) async { await tester.pumpWidget(buildCard(note: noteWithoutLocation)); - expect(find.text('Edited May 15, 2025'), findsOneWidget); }); testWidgets('hides tags section when no tags', (tester) async { await tester.pumpWidget(buildCard(note: noteWithoutLocation)); - expect(find.byType(Chip), findsNothing); }); }); @@ -114,30 +104,24 @@ void main() { testWidgets('tap calls onTap', (tester) async { var tapped = false; await tester.pumpWidget(buildCard(onTap: () => tapped = true)); - await tester.tap(find.byType(NoteCard)); await tester.pump(); - expect(tapped, isTrue); }); testWidgets('long press calls onLongPress', (tester) async { var longPressed = false; await tester.pumpWidget(buildCard(onLongPress: () => longPressed = true)); - await tester.longPress(find.byType(NoteCard)); await tester.pump(); - expect(longPressed, isTrue); }); testWidgets('action menu button calls onLongPress', (tester) async { var pressed = false; await tester.pumpWidget(buildCard(onLongPress: () => pressed = true)); - await tester.tap(find.byIcon(Icons.more_vert)); await tester.pump(); - expect(pressed, isTrue); }); }); diff --git a/app/test/widgets/book_details/note_dialog_test.dart b/app/test/widgets/book_details/note_dialog_test.dart index 1b5e099e..8c6db837 100644 --- a/app/test/widgets/book_details/note_dialog_test.dart +++ b/app/test/widgets/book_details/note_dialog_test.dart @@ -20,8 +20,8 @@ void main() { await tester.tap(find.text('Add note')); await tester.pumpAndSettle(); - final contentField = find.byKey(const Key('note-content-field')); + final editableText = tester.widget( find.descendant(of: contentField, matching: find.byType(EditableText)), ); @@ -29,10 +29,8 @@ void main() { expect(editableText.expands, isFalse); expect(editableText.minLines, 8); expect(editableText.maxLines, 12); - await tester.drag(find.byType(BottomSheetHandle), const Offset(0, -300)); await tester.pumpAndSettle(); - final sheetBottom = tester.getBottomRight(find.byKey(const Key('note-bottom-sheet'))).dy; final tagsBottom = tester.getBottomRight(find.text('Tags will appear here')).dy; expect(sheetBottom - tagsBottom, lessThan(80)); diff --git a/app/test/widgets/buttons/google_sign_in_test.dart b/app/test/widgets/buttons/google_sign_in_test.dart index 0119591e..06dda3a3 100644 --- a/app/test/widgets/buttons/google_sign_in_test.dart +++ b/app/test/widgets/buttons/google_sign_in_test.dart @@ -51,16 +51,20 @@ void main() { tester.view.physicalSize = layout.screen; addTearDown(tester.view.reset); final repository = _PendingGoogleRepository(); + final auth = AuthProvider( await SharedPreferences.getInstance(), repository: repository, bootstrapOnCreate: false, ); + await auth.bootstrap(); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); auth.dispose(); }); + await tester.pumpWidget( ChangeNotifierProvider.value( value: auth, @@ -74,6 +78,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final button = find.descendant(of: find.byType(GoogleSignInButton), matching: find.byType(OutlinedButton)); await tester.ensureVisible(button); @@ -108,20 +113,25 @@ void main() { } } } + for (final theme in [AppTheme.dark, AppTheme.eink]) { testWidgets('Google loading keeps button size at enlarged text in ${theme.brightness}', (tester) async { SharedPreferences.setMockInitialValues({}); final repository = _PendingGoogleRepository(); + final auth = AuthProvider( await SharedPreferences.getInstance(), repository: repository, bootstrapOnCreate: false, ); + await auth.bootstrap(); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); auth.dispose(); }); + await tester.pumpWidget( ChangeNotifierProvider.value( value: auth, @@ -138,6 +148,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final button = find.byType(OutlinedButton); final bounds = tester.getRect(button); diff --git a/app/test/widgets/context_menu/book_context_menu_test.dart b/app/test/widgets/context_menu/book_context_menu_test.dart index 53488bd7..3f088a42 100644 --- a/app/test/widgets/context_menu/book_context_menu_test.dart +++ b/app/test/widgets/context_menu/book_context_menu_test.dart @@ -34,15 +34,12 @@ void main() { await tester.tap(find.text('Open menu')); await tester.pumpAndSettle(); - final downloadTop = tester.getTopLeft(find.text('Download')).dy; final deleteTop = tester.getTopLeft(find.text('Delete')).dy; expect(downloadTop, lessThan(deleteTop)); - await tester.ensureVisible(find.text('Download')); await tester.tap(find.text('Download')); await tester.pumpAndSettle(); - expect(downloaded, isTrue); }); } diff --git a/app/test/widgets/goals/goal_details_sheets_test.dart b/app/test/widgets/goals/goal_details_sheets_test.dart index 2f5f1ce4..dca6b2f0 100644 --- a/app/test/widgets/goals/goal_details_sheets_test.dart +++ b/app/test/widgets/goals/goal_details_sheets_test.dart @@ -20,6 +20,7 @@ void main() { 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, @@ -31,7 +32,9 @@ void main() { 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++) @@ -45,11 +48,13 @@ void main() { ), ], ); + 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, @@ -63,6 +68,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); expect(find.text('30 sec / 30m'), findsOneWidget); @@ -82,11 +88,13 @@ void main() { 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, @@ -96,10 +104,12 @@ void main() { 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, @@ -113,6 +123,7 @@ void main() { ), ), ); + await tester.tap(find.text('Edit')); await tester.pumpAndSettle(); expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); @@ -127,12 +138,14 @@ void main() { expect(provider.current.single.goal.type, GoalType.pages); expect(tester.takeException(), isNull); }); + testWidgets('an unchanged DST deadline remains a target edit rather than a replacement', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(1000, 1400); addTearDown(tester.view.reset); final now = DateTime.utc(2026, 3, 28, 12); final end = GoalCalendar.deadline(DateTime(2026, 3, 29), 'Europe/Vilnius'); + final goal = ReadingGoal( id: 'deadline', type: GoalType.books, @@ -143,10 +156,12 @@ void main() { timezone: 'Europe/Vilnius', isRecurring: false, ); + final store = DataStore()..loadData(readingGoals: [goal]); final provider = GoalsProvider(now: () => now, watchClock: false)..attach(store); addTearDown(provider.dispose); addTearDown(store.dispose); + await tester.pumpWidget( MaterialApp( theme: AppTheme.light, @@ -160,6 +175,7 @@ void main() { ), ), ); + await tester.tap(find.text('Edit')); await tester.pumpAndSettle(); expect(find.text('Deadline: 29/3/2026'), findsOneWidget); @@ -171,6 +187,7 @@ void main() { expect(store.getReadingGoal('deadline')!.isArchived, isFalse); expect(tester.takeException(), isNull); }); + for (final size in [const Size(390, 844), const Size(1200, 1000)]) { testWidgets('details show a plain scoped summary with activity and live actions at $size', (tester) async { tester.view.devicePixelRatio = 1; @@ -178,6 +195,7 @@ void main() { 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, @@ -188,7 +206,9 @@ void main() { 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( @@ -202,9 +222,11 @@ void main() { ), ], ); + final provider = GoalsProvider(watchClock: false)..attach(store); addTearDown(provider.dispose); addTearDown(store.dispose); + await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, @@ -222,6 +244,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); expect(find.byType(GoalCard), findsNothing); @@ -257,6 +280,7 @@ void main() { expect(tester.takeException(), isNull); }); } + for (final width in [320.0, 390.0, 1200.0]) { for (final theme in [AppTheme.light, AppTheme.dark, AppTheme.eink]) { testWidgets('goal sheet footer is above system navigation at $width/${theme.brightness}', (tester) async { @@ -266,6 +290,7 @@ void main() { tester.view.viewPadding = const FakeViewPadding(bottom: 48); addTearDown(tester.view.reset); final now = DateTime.now().toUtc(); + final goal = ReadingGoal( id: 'goal', type: GoalType.books, @@ -275,10 +300,12 @@ void main() { 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, @@ -292,6 +319,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); final done = find.widgetWithText(FilledButton, 'Done'); @@ -302,10 +330,12 @@ void main() { expect(tester.getRect(done).bottom, lessThanOrEqualTo(652)); expect(tester.getRect(delete).top, tester.getRect(done).top); expect(find.widgetWithText(OutlinedButton, 'Delete goal'), findsNothing); + if (width < 600) { expect(tester.getSize(delete).width, closeTo(tester.getSize(done).width, 1)); expect(tester.getSize(find.text('Delete')).height, lessThan(tester.getSize(delete).height)); } + expect(tester.takeException(), isNull); await tester.tap(done); await tester.pumpAndSettle(); diff --git a/app/test/widgets/goals/goals_ui_refinement_test.dart b/app/test/widgets/goals/goals_ui_refinement_test.dart index 169af9f2..20144e55 100644 --- a/app/test/widgets/goals/goals_ui_refinement_test.dart +++ b/app/test/widgets/goals/goals_ui_refinement_test.dart @@ -18,12 +18,14 @@ 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, @@ -34,6 +36,7 @@ void main() { startDate: now, endDate: now.add(const Duration(days: 1)), ); + final days = ReadingGoal( id: 'days', type: GoalType.days, @@ -51,6 +54,7 @@ void main() { Future showSheet(WidgetTester tester, void Function(BuildContext) show, {ThemeData? theme}) async { await size(tester); + await tester.pumpWidget( MaterialApp( theme: theme ?? AppTheme.dark, @@ -61,6 +65,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); } @@ -76,12 +81,14 @@ void main() { 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'))); @@ -102,9 +109,11 @@ void main() { 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); }); @@ -112,32 +121,39 @@ void main() { 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'); @@ -146,6 +162,7 @@ void main() { testWidgets('deletion confirms directly from the card menu and preserves activity', (tester) async { await size(tester); + final entry = ReadingActivity( id: 'entry', bookId: book.id, @@ -155,15 +172,18 @@ void main() { 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(); @@ -199,12 +219,14 @@ void main() { 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(); @@ -227,6 +249,7 @@ void main() { addTearDown(tester.view.reset); final store = DataStore()..loadData(); final goals = provider(store); + await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, @@ -244,6 +267,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); final cancel = find.widgetWithText(OutlinedButton, 'Cancel'); @@ -300,11 +324,13 @@ void main() { 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); @@ -321,9 +347,11 @@ void main() { 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); @@ -332,6 +360,7 @@ void main() { 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]); @@ -383,12 +412,14 @@ void main() { 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)); @@ -418,10 +449,12 @@ void main() { 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)); @@ -458,6 +491,7 @@ void main() { addTearDown(tester.view.reset); final date = DateTime.utc(2026, 10, 7); DateTime? selected; + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -483,6 +517,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(tester.getSize(find.byKey(const Key('reading-activity-heatmap'))).width, width); expect(find.text('1 active day'), findsOneWidget); @@ -499,6 +534,7 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(320, 1000); addTearDown(tester.view.reset); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -518,6 +554,7 @@ void main() { ), ), ); + 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)); @@ -529,6 +566,7 @@ void main() { 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( @@ -542,6 +580,7 @@ void main() { createdAt: now, ), ]; + await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, @@ -550,6 +589,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(find.text(book.title), findsOneWidget); expect(find.text('2m · Reader'), findsOneWidget); @@ -566,6 +606,7 @@ void main() { await size(tester); final store = DataStore()..loadData(books: [book]); final goals = provider(store); + final entries = [ for (var i = 0; i < 3; i++) ReadingActivity( @@ -579,6 +620,7 @@ void main() { pages: i == 2 ? 10 : 0, ), ]; + await tester.pumpWidget( MaterialApp( theme: AppTheme.dark, @@ -587,6 +629,7 @@ void main() { ), ), ); + expect(find.text(book.title), findsOneWidget); expect(find.text('1 min · Reader'), findsOneWidget); expect(find.text('2 min · Reader · Corrected'), findsOneWidget); diff --git a/app/test/widgets/goals/reading_activity_details_test.dart b/app/test/widgets/goals/reading_activity_details_test.dart index 06620456..964c345b 100644 --- a/app/test/widgets/goals/reading_activity_details_test.dart +++ b/app/test/widgets/goals/reading_activity_details_test.dart @@ -19,6 +19,7 @@ void main() { 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, @@ -29,11 +30,13 @@ void main() { 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, @@ -46,6 +49,7 @@ void main() { ), ), ); + await tester.tap(find.text(book.title)); await tester.pumpAndSettle(); expect(find.text(book.author), findsOneWidget); @@ -63,16 +67,23 @@ void main() { 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( + store.readingActivities.any((activity) => activity.kind == 'reversal' && activity.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, @@ -82,11 +93,13 @@ void main() { 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, @@ -100,6 +113,7 @@ void main() { ), ), ); + await tester.tap(find.text('Correct')); await tester.pumpAndSettle(); expect(find.byType(SearchableBookField), findsNothing); @@ -107,20 +121,29 @@ void main() { 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( + store.readingActivities.where((activity) => activity.kind == 'reversal' && activity.correctionOf == entry.id), + hasLength(1), + ); + + final replacement = store.readingActivities.singleWhere((activity) => activity.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', @@ -130,10 +153,12 @@ void main() { 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, @@ -142,6 +167,7 @@ void main() { ), ), ); + await tester.tap(find.text(entry.bookTitle)); await tester.pumpAndSettle(); expect(find.text(entry.bookTitle), findsWidgets); diff --git a/app/test/widgets/library/acquisition_confirmation_dialog_test.dart b/app/test/widgets/library/acquisition_confirmation_dialog_test.dart index 09be34ec..bbd37ddd 100644 --- a/app/test/widgets/library/acquisition_confirmation_dialog_test.dart +++ b/app/test/widgets/library/acquisition_confirmation_dialog_test.dart @@ -6,36 +6,28 @@ import 'package:papyrus/widgets/library/acquisition_confirmation_dialog.dart'; void main() { testWidgets('uses the shelf-style destructive confirmation and returns true', (tester) async { await tester.pumpWidget(const _ConfirmationLauncher()); - await tester.tap(find.text('Open confirmation')); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(BottomSheet), findsNothing); expect(find.text('Cancel downloads'), findsNWidgets(2)); expect(find.text('Cancel 2 selected downloads?'), findsOneWidget); expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); - final destructiveAction = find.widgetWithText(FilledButton, 'Cancel downloads'); final button = tester.widget(destructiveAction); final colorScheme = Theme.of(tester.element(destructiveAction)).colorScheme; - expect(button.style?.backgroundColor?.resolve({}), colorScheme.error); - await tester.tap(destructiveAction); await tester.pumpAndSettle(); - expect(find.text('confirmed: true'), findsOneWidget); }); testWidgets('returns false when the confirmation is cancelled', (tester) async { await tester.pumpWidget(const _ConfirmationLauncher()); - await tester.tap(find.text('Open confirmation')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, 'Cancel')); await tester.pumpAndSettle(); - expect(find.text('confirmed: false'), findsOneWidget); }); } diff --git a/app/test/widgets/library/acquisition_job_sheets_test.dart b/app/test/widgets/library/acquisition_job_sheets_test.dart index 469fc8cb..ed197775 100644 --- a/app/test/widgets/library/acquisition_job_sheets_test.dart +++ b/app/test/widgets/library/acquisition_job_sheets_test.dart @@ -16,6 +16,7 @@ void main() { final gateway = _RecordingGateway(); final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); + final job = _job( status: AcquisitionJobStatus.downloading, selectedFilePath: '/downloads/Example Book.epub', @@ -25,7 +26,6 @@ void main() { await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsOneWidget); expect(find.byType(AlertDialog), findsNothing); expect(find.byType(BottomSheetHandle), findsOneWidget); @@ -42,6 +42,7 @@ void main() { expect(find.text('hash-secret'), findsNothing); expect(find.text('technical-client-state'), findsNothing); expect(tester.widget(find.byType(LinearProgressIndicator)).value, 0.42); + expect( tester.getSemantics(find.byKey(const Key('acquisition-job-details-title'))).flagsCollection.isHeader, isTrue, @@ -50,8 +51,8 @@ void main() { final title = tester.widget(find.text('Example Book')); final titleContext = tester.element(find.text('Example Book')); expect(title.style, Theme.of(titleContext).textTheme.headlineSmall); - final sheet = tester.widget(find.byType(BottomSheet)); + expect( sheet.shape, const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl))), @@ -59,27 +60,25 @@ void main() { final content = find.byKey(const Key('acquisition-job-details-content')); expect(find.descendant(of: content, matching: find.byType(AppBottomSheet)), findsOneWidget); - expect(tester.getSize(find.byType(BottomSheet)).height, lessThan(900)); - semantics.dispose(); }); testWidgets('shows only Cancel for an active download', (tester) async { final job = _job(status: AcquisitionJobStatus.downloading); + final gateway = _RecordingGateway( jobResponses: [ [job], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.widgetWithText(FilledButton, 'Cancel'), findsOneWidget); expect(find.text('Retry import'), findsNothing); expect(find.byIcon(Icons.menu_book_outlined), findsNothing); @@ -87,49 +86,47 @@ void main() { testWidgets('confirms and cancels one job without using another bottom sheet', (tester) async { final job = _job(status: AcquisitionJobStatus.downloading); + final gateway = _RecordingGateway( jobResponses: [ [job], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Cancel')); await tester.pumpAndSettle(); - expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(BottomSheet), findsNothing); expect(find.text('Cancel download'), findsNWidgets(2)); expect(find.text('Cancel "Example Book"?'), findsOneWidget); - final destructiveAction = find.widgetWithText(FilledButton, 'Cancel download'); final button = tester.widget(destructiveAction); final colorScheme = Theme.of(tester.element(destructiveAction)).colorScheme; expect(button.style?.backgroundColor?.resolve({}), colorScheme.error); - await tester.tap(destructiveAction); await tester.pumpAndSettle(); - expect(gateway.cancelledJobIds, ['job-1']); }); testWidgets('failed cancellation shows a safe message and reopens the actionable details', (tester) async { final job = _job(status: AcquisitionJobStatus.downloading); + final gateway = _RecordingGateway( jobResponses: [ [job], ], cancelError: StateError('raw cancel failure at https://client.invalid?token=secret'), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); @@ -137,7 +134,6 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Cancel download')); await tester.pumpAndSettle(); - expect(find.text('Could not cancel the download. Try again.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); expect(find.textContaining('secret'), findsNothing); @@ -147,6 +143,7 @@ void main() { testWidgets('offers supported files only when file selection is needed', (tester) async { final job = _job(status: AcquisitionJobStatus.needsFileSelection); + final gateway = _RecordingGateway( jobResponses: [ [job], @@ -170,28 +167,26 @@ void main() { ), ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.text('Select file'), findsOneWidget); expect(find.text('Example Book.epub'), findsOneWidget); expect(find.text('2.0 MB'), findsOneWidget); expect(find.text('Cover.jpg'), findsNothing); expect(find.text('Retry import'), findsNothing); - await tester.tap(find.text('Example Book.epub')); await tester.pumpAndSettle(); - expect(gateway.selectedFile, ('job-1', 2)); }); testWidgets('failed file selection shows a safe message and keeps the choice retryable', (tester) async { final job = _job(status: AcquisitionJobStatus.needsFileSelection); + final gateway = _RecordingGateway( jobResponses: [ [job], @@ -208,16 +203,15 @@ void main() { ], selectFileError: StateError('raw file failure at https://client.invalid?token=secret'), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); await tester.tap(find.text('Example Book.epub')); await tester.pumpAndSettle(); - expect(find.text('Could not select the download file. Try again.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); expect(find.textContaining('secret'), findsNothing); @@ -227,48 +221,46 @@ void main() { testWidgets('offers Retry import only for retryable failed imports', (tester) async { final job = _job(status: AcquisitionJobStatus.failed, submittedAt: DateTime(2026)); + final gateway = _RecordingGateway( jobResponses: [ [job], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.widgetWithText(FilledButton, 'Retry import'), findsOneWidget); expect(find.text('Cancel'), findsNothing); expect(find.text('Select file'), findsNothing); expect(find.textContaining('technical backend failure'), findsNothing); - await tester.tap(find.widgetWithText(FilledButton, 'Retry import')); await tester.pumpAndSettle(); - expect(gateway.retriedJobIds, ['job-1']); }); testWidgets('failed import retry shows a safe message and reopens the actionable details', (tester) async { final job = _job(status: AcquisitionJobStatus.failed, submittedAt: DateTime(2026)); + final gateway = _RecordingGateway( jobResponses: [ [job], ], retryError: StateError('raw retry failure at https://client.invalid?token=secret'), ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Retry import')); await tester.pumpAndSettle(); - expect(find.text('Could not retry the download import. Try again.'), findsOneWidget); expect(find.textContaining('client.invalid'), findsNothing); expect(find.textContaining('secret'), findsNothing); @@ -278,19 +270,19 @@ void main() { testWidgets('terminal jobs expose no contextual actions', (tester) async { final job = _job(status: AcquisitionJobStatus.completed); + final gateway = _RecordingGateway( jobResponses: [ [job], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.text('Cancel'), findsNothing); expect(find.text('Retry import'), findsNothing); expect(find.text('Select file'), findsNothing); @@ -306,9 +298,9 @@ void main() { job: _job(status: AcquisitionJobStatus.unknown), useCompatibilityWrapper: true, ); + await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.byType(BottomSheet), findsOneWidget); expect(find.text('Example Book'), findsOneWidget); expect(find.text('Needs attention'), findsOneWidget); @@ -318,26 +310,24 @@ void main() { testWidgets('rebuilds details from live provider job updates', (tester) async { final downloading = _job(status: AcquisitionJobStatus.downloading); final importing = _job(status: AcquisitionJobStatus.importing); + final gateway = _RecordingGateway( jobResponses: [ [downloading], [importing], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: downloading); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.text('Downloading 42%'), findsOneWidget); expect(find.widgetWithText(FilledButton, 'Cancel'), findsOneWidget); - await provider.refreshJobs(); await tester.pump(); - expect(find.text('Adding to library'), findsOneWidget); expect(find.text('Downloading 42%'), findsNothing); expect(find.text('Cancel'), findsNothing); @@ -346,26 +336,25 @@ void main() { testWidgets('suppresses stale cancellation after the live job completes', (tester) async { final downloading = _job(status: AcquisitionJobStatus.downloading); final completed = _job(status: AcquisitionJobStatus.completed); + final gateway = _RecordingGateway( jobResponses: [ [downloading], [completed], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: downloading); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(FilledButton, 'Cancel')); await tester.pumpAndSettle(); - await provider.refreshJobs(); await tester.tap(find.widgetWithText(FilledButton, 'Cancel download')); await tester.pumpAndSettle(); - expect(gateway.cancelledJobIds, isEmpty); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.completed); }); @@ -373,25 +362,24 @@ void main() { testWidgets('suppresses a stale import retry after the live job completes', (tester) async { final failed = _job(status: AcquisitionJobStatus.failed, submittedAt: DateTime(2026)); final completed = _job(status: AcquisitionJobStatus.completed); + final gateway = _RecordingGateway( jobResponses: [ [failed], [completed], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: failed); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); final staleRetry = tester.widget(find.widgetWithText(FilledButton, 'Retry import')).onPressed!; - await provider.refreshJobs(); staleRetry(); await tester.pumpAndSettle(); - expect(gateway.retriedJobIds, isEmpty); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.completed); }); @@ -399,6 +387,7 @@ void main() { testWidgets('suppresses a stale file selection after the live job completes', (tester) async { final needsFile = _job(status: AcquisitionJobStatus.needsFileSelection); final completed = _job(status: AcquisitionJobStatus.completed); + final gateway = _RecordingGateway( jobResponses: [ [needsFile], @@ -415,13 +404,14 @@ void main() { ), ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: needsFile); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); + final staleFileSelection = tester .widget(find.ancestor(of: find.text('Example Book.epub'), matching: find.byType(ListTile))) .onTap!; @@ -429,7 +419,6 @@ void main() { await provider.refreshJobs(); staleFileSelection(); await tester.pumpAndSettle(); - expect(gateway.selectedFile, isNull); expect(provider.jobById('job-1')?.status, AcquisitionJobStatus.completed); }); @@ -437,40 +426,40 @@ void main() { testWidgets('opens immediately and shows loading while file choices are pending', (tester) async { final job = _job(status: AcquisitionJobStatus.needsFileSelection); final filesCompleter = Completer>(); + addTearDown(() { if (!filesCompleter.isCompleted) { filesCompleter.complete(const []); } }); + final gateway = _RecordingGateway( jobResponses: [ [job], ], fileResponses: [filesCompleter.future], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - expect(find.byType(BottomSheet), findsOneWidget); expect(find.text('Loading files…'), findsOneWidget); expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.text('No supported book files found.'), findsNothing); - filesCompleter.complete(const []); await tester.pumpAndSettle(); - expect(find.text('Loading files…'), findsNothing); expect(find.text('No supported book files found.'), findsOneWidget); }); testWidgets('shows a safe file error and retries in the sheet', (tester) async { final job = _job(status: AcquisitionJobStatus.needsFileSelection); + final gateway = _RecordingGateway( jobResponses: [ [job], @@ -489,23 +478,20 @@ void main() { ], ], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.text('Could not load download files. Try again.'), findsOneWidget); expect(find.textContaining('client.local'), findsNothing); expect(find.textContaining('secret'), findsNothing); expect(find.widgetWithText(OutlinedButton, 'Retry'), findsOneWidget); expect(find.text('No supported book files found.'), findsNothing); - await tester.tap(find.widgetWithText(OutlinedButton, 'Retry')); await tester.pumpAndSettle(); - expect(gateway.fileLoadCalls, 2); expect(find.text('Retry Book.epub'), findsOneWidget); expect(find.text('Could not load download files. Try again.'), findsNothing); @@ -514,20 +500,20 @@ void main() { testWidgets('shows the genuine empty state only after a successful file response', (tester) async { final job = _job(status: AcquisitionJobStatus.needsFileSelection); + final gateway = _RecordingGateway( jobResponses: [ [job], ], fileResponses: const [[]], ); + final provider = AcquisitionDownloadsProvider(gateway: gateway, pollingInterval: Duration.zero); addTearDown(provider.dispose); await provider.refreshJobs(); - await _pumpLauncher(tester, provider: provider, job: job); await tester.tap(find.text('Open details')); await tester.pumpAndSettle(); - expect(find.text('No supported book files found.'), findsOneWidget); expect(find.text('Could not load download files. Try again.'), findsNothing); expect(find.text('Retry'), findsNothing); @@ -595,7 +581,6 @@ class _RecordingGateway implements AcquisitionDownloadsGateway { } cancelledJobIds.add(jobId); - return _job(status: AcquisitionJobStatus.cancelled); } @@ -618,6 +603,7 @@ class _RecordingGateway implements AcquisitionDownloadsGateway { if (response is Future>) { return response; } + if (response is List) { return response; } @@ -644,7 +630,6 @@ class _RecordingGateway implements AcquisitionDownloadsGateway { } retriedJobIds.add(jobId); - return _job(status: AcquisitionJobStatus.importing); } @@ -658,7 +643,6 @@ class _RecordingGateway implements AcquisitionDownloadsGateway { } selectedFile = (jobId, fileIndex); - return _job(status: AcquisitionJobStatus.importing); } diff --git a/app/test/widgets/library/acquisition_job_visibility_test.dart b/app/test/widgets/library/acquisition_job_visibility_test.dart index 6df901bd..8384b5c4 100644 --- a/app/test/widgets/library/acquisition_job_visibility_test.dart +++ b/app/test/widgets/library/acquisition_job_visibility_test.dart @@ -29,9 +29,7 @@ void main() { group('buildAcquisitionLibraryItems', () { test('links an active job to its synchronized book', () { final job = _job(id: 'job-1', bookId: 'book-1'); - final items = buildAcquisitionLibraryItems(books: [_book('book-1')], jobs: [job]); - expect(items.linkedJobsByBookId, {'book-1': job}); expect(items.orphanJobs, isEmpty); expect(items.downloadingBookIds, {'book-1'}); @@ -41,9 +39,7 @@ void main() { test('keeps an active orphan as a placeholder', () { final job = _job(id: 'job-1', bookId: null); - final items = buildAcquisitionLibraryItems(books: const [], jobs: [job]); - expect(items.linkedJobsByBookId, isEmpty); expect(items.orphanJobs, [job]); expect(items.downloadingBookIds, isEmpty); @@ -53,10 +49,8 @@ void main() { test('does not resurrect a completed job as an orphan placeholder', () { final job = _job(id: 'job-1', bookId: 'book-1', status: AcquisitionJobStatus.completed); - final pendingItems = buildAcquisitionLibraryItems(books: const [], jobs: [job]); final synchronizedItems = buildAcquisitionLibraryItems(books: [_book('book-1')], jobs: [job]); - expect(pendingItems.orphanJobs, isEmpty); expect(pendingItems.hasDownloadingItems, isFalse); expect(synchronizedItems.linkedJobsByBookId, isEmpty); @@ -66,9 +60,7 @@ void main() { test('keeps cancelled orphan in All until removal', () { final job = _job(id: 'job-1', bookId: null, status: AcquisitionJobStatus.cancelled); - final items = buildAcquisitionLibraryItems(books: const [], jobs: [job]); - expect(items.orphanJobs, [job]); expect(items.downloadingOrphanJobs, isEmpty); expect(items.hasDownloadingItems, isFalse); @@ -76,9 +68,7 @@ void main() { test('does not create a placeholder for a synchronized book', () { final job = _job(id: 'job-1', bookId: 'book-1'); - final items = buildAcquisitionLibraryItems(books: [_book('book-1')], jobs: [job]); - expect(items.linkedJobsByBookId.values, [job]); expect(items.orphanJobs, isEmpty); expect(items.downloadingOrphanJobs, isEmpty); @@ -88,9 +78,7 @@ void main() { final first = _job(id: 'job-1', bookId: 'pending-book'); final duplicateId = _job(id: 'job-1', bookId: null); final duplicateBook = _job(id: 'job-2', bookId: 'pending-book'); - final items = buildAcquisitionLibraryItems(books: const [], jobs: [first, duplicateId, duplicateBook]); - expect(items.orphanJobs, [first]); expect(items.downloadingOrphanJobs, [first]); }); diff --git a/app/test/widgets/library/acquisition_placeholder_card_test.dart b/app/test/widgets/library/acquisition_placeholder_card_test.dart index d5b84adc..af4ca4f3 100644 --- a/app/test/widgets/library/acquisition_placeholder_card_test.dart +++ b/app/test/widgets/library/acquisition_placeholder_card_test.dart @@ -72,7 +72,6 @@ void main() { for (final entry in expectedLabels.entries) { testWidgets('renders ${entry.key.name}', (tester) async { await tester.pumpWidget(_buildCard(job: _job(status: entry.key))); - expect(find.text('A Downloading Book'), findsOneWidget); expect(find.text(entry.value), findsOneWidget); }); @@ -105,10 +104,8 @@ void main() { try { await tester.pumpWidget(_buildCard(job: _job(status: AcquisitionJobStatus.failed))); - final status = tester.widget(find.text('Download failed')); final node = tester.getSemantics(find.byKey(const ValueKey('acquisition-placeholder-card-job-1'))); - expect(find.text('Disk full'), findsNothing); expect(node.label, isNot(contains('Disk full'))); expect(status.style?.color, AppTheme.light.colorScheme.error); @@ -119,7 +116,6 @@ void main() { testWidgets('uses a neutral cover rather than release artwork', (tester) async { await tester.pumpWidget(_buildCard(job: _job())); - expect(find.byIcon(Icons.menu_book_outlined), findsOneWidget); expect(find.byType(CoverImage), findsNothing); }); @@ -141,17 +137,14 @@ void main() { final finder = find.byKey(const ValueKey('acquisition-placeholder-card-job-1')); final node = tester.getSemantics(finder); - expect(node.label, contains('A Downloading Book')); expect(node.label, contains('Downloading 42%')); expect(node.flagsCollection.isButton, isTrue); expect(node.flagsCollection.isSelected, Tristate.isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.longPress), isTrue); - await tester.tap(finder); await tester.longPress(finder); - expect(taps, 1); expect(longPresses, 1); } finally { @@ -168,7 +161,6 @@ void main() { ); await tester.tap(find.byKey(const ValueKey('acquisition-placeholder-card-job-1'))); - expect(taps, 0); expect(toggles, 1); }); @@ -191,7 +183,6 @@ void main() { tester.semantics.tap(find.semantics.byLabel('Select A Downloading Book')); await tester.tap(find.byIcon(Icons.radio_button_unchecked)); - expect(selections, 2); expect(details, 0); } finally { @@ -212,12 +203,9 @@ void main() { final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); await pointer.addPointer(location: tester.getCenter(find.byType(AcquisitionPlaceholderCard))); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(AnimatedOpacity)).opacity, 1); - await pointer.moveTo(tester.getCenter(find.byKey(const ValueKey('acquisition-selector-job-1')))); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(AnimatedOpacity)).opacity, 1); }); @@ -226,9 +214,7 @@ void main() { try { await tester.pumpWidget(_buildCard(job: _job())); - final node = tester.getSemantics(find.byKey(const ValueKey('acquisition-placeholder-card-job-1'))); - expect(node.flagsCollection.isButton, isFalse); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); expect(node.getSemanticsData().hasAction(SemanticsAction.longPress), isFalse); @@ -239,13 +225,11 @@ void main() { testWidgets('matches selected BookCard Card treatment in the light theme', (tester) async { await tester.pumpWidget(_buildCardPair(theme: AppTheme.light)); - _expectCardParity(tester, AppTheme.light); }); testWidgets('matches selected BookCard Card treatment in the e-ink theme', (tester) async { await tester.pumpWidget(_buildCardPair(theme: AppTheme.eink)); - _expectCardParity(tester, AppTheme.eink); }); @@ -305,11 +289,12 @@ void _expectCardParity(WidgetTester tester, ThemeData theme) { final bookMaterial = tester.widget(_cardMaterial(bookRoot)); final placeholderMaterial = tester.widget(_cardMaterial(placeholderRoot)); final bookMargin = tester.widget(find.descendant(of: bookRoot, matching: find.byType(Padding)).first); + final placeholderMargin = tester.widget( find.descendant(of: placeholderRoot, matching: find.byType(Padding)).first, ); - final selectionColor = theme.colorScheme.primary.withValues(alpha: 0.15); + final selectionColor = theme.colorScheme.primary.withValues(alpha: 0.15); expect(tester.getSize(bookRoot), tester.getSize(placeholderRoot)); expect(placeholderCard.margin, bookCard.margin); expect(placeholderCard.elevation, bookCard.elevation); diff --git a/app/test/widgets/library/acquisition_placeholder_list_item_test.dart b/app/test/widgets/library/acquisition_placeholder_list_item_test.dart index a0b035f1..d73b5b16 100644 --- a/app/test/widgets/library/acquisition_placeholder_list_item_test.dart +++ b/app/test/widgets/library/acquisition_placeholder_list_item_test.dart @@ -27,7 +27,6 @@ void main() { for (final entry in expectedLabels.entries) { testWidgets('renders ${entry.key.name}', (tester) async { await tester.pumpWidget(_buildListItem(job: _job(status: entry.key))); - expect(find.text('A Downloading Book'), findsOneWidget); expect(find.text(entry.value), findsOneWidget); }); @@ -60,10 +59,8 @@ void main() { try { await tester.pumpWidget(_buildListItem(job: _job(status: AcquisitionJobStatus.failed))); - final status = tester.widget(find.text('Download failed')); final node = tester.getSemantics(find.byKey(const ValueKey('acquisition-placeholder-list-item-job-1'))); - expect(find.text('Disk full'), findsNothing); expect(node.label, isNot(contains('Disk full'))); expect(status.style?.color, AppTheme.light.colorScheme.error); @@ -74,7 +71,6 @@ void main() { testWidgets('uses a neutral cover rather than release artwork', (tester) async { await tester.pumpWidget(_buildListItem(job: _job())); - expect(find.byIcon(Icons.menu_book_outlined), findsOneWidget); expect(find.byType(CoverImage), findsNothing); }); @@ -96,17 +92,14 @@ void main() { final finder = find.byKey(const ValueKey('acquisition-placeholder-list-item-job-1')); final node = tester.getSemantics(finder); - expect(node.label, contains('A Downloading Book')); expect(node.label, contains('Downloading 42%')); expect(node.flagsCollection.isButton, isTrue); expect(node.flagsCollection.isSelected, Tristate.isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.longPress), isTrue); - await tester.tap(finder); await tester.longPress(finder); - expect(taps, 1); expect(longPresses, 1); } finally { @@ -123,7 +116,6 @@ void main() { ); await tester.tap(find.byKey(const ValueKey('acquisition-placeholder-list-item-job-1'))); - expect(taps, 0); expect(toggles, 1); }); @@ -133,9 +125,7 @@ void main() { try { await tester.pumpWidget(_buildListItem(job: _job())); - final node = tester.getSemantics(find.byKey(const ValueKey('acquisition-placeholder-list-item-job-1'))); - expect(node.flagsCollection.isButton, isFalse); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); expect(node.getSemanticsData().hasAction(SemanticsAction.longPress), isFalse); @@ -172,15 +162,19 @@ void main() { final bookRoot = find.byType(BookListItem); final placeholderRoot = find.byType(AcquisitionPlaceholderListItem); + final bookMaterial = tester.widget( find.descendant(of: bookRoot, matching: find.byType(Material)).first, ); + final placeholderMaterial = tester.widget( find.descendant(of: placeholderRoot, matching: find.byType(Material)).first, ); + final bookContainer = tester.widget( find.descendant(of: bookRoot, matching: find.byType(Container)).first, ); + final placeholderContainer = tester.widget( find.descendant(of: placeholderRoot, matching: find.byType(Container)).first, ); diff --git a/app/test/widgets/library/book_card_test.dart b/app/test/widgets/library/book_card_test.dart index 8143f716..ac006278 100644 --- a/app/test/widgets/library/book_card_test.dart +++ b/app/test/widgets/library/book_card_test.dart @@ -101,6 +101,7 @@ void main() { testWidgets('shows an accessible icon-only account status', (tester) async { final semantics = tester.ensureSemantics(); + try { await tester.pumpWidget( buildCard(accountStatus: BookAccountStatus.saved, deviceStatus: BookDeviceStatus.missing), @@ -122,6 +123,7 @@ void main() { testWidgets('compact physical card preserves format and saved status without overlapping badges', (tester) async { var opened = false; + await tester.pumpWidget( buildCard( book: testBook.copyWith(isPhysical: true), @@ -131,6 +133,7 @@ void main() { onTap: () => opened = true, ), ); + expect(find.byTooltip('Physical'), findsOneWidget); expect(find.byTooltip('Saved'), findsOneWidget); expect(tester.getRect(find.byTooltip('Physical')).overlaps(tester.getRect(find.byTooltip('Saved'))), isFalse); @@ -147,6 +150,7 @@ void main() { tester, ) async { final book = testBook.copyWith(isPhysical: physical); + await tester.pumpWidget( buildCard( book: book, @@ -157,6 +161,7 @@ void main() { textScaler: const TextScaler.linear(2), ), ); + final formatIcon = find.byIcon(physical ? Icons.menu_book_outlined : Icons.description_outlined); final accountIcon = find.byIcon(Icons.cloud_done_outlined); final formatSurface = find.ancestor(of: formatIcon, matching: find.byType(Container)).first; @@ -219,18 +224,14 @@ void main() { testWidgets('does not invent acquisition progress', (tester) async { await tester.pumpWidget(buildCard(book: testBook, acquisitionJob: _acquisitionJob(progressBasisPoints: null))); - expect(find.text('Downloading'), findsOneWidget); expect(find.byType(LinearProgressIndicator), findsNothing); }); testWidgets('hides ordinary desktop menu for linked jobs only', (tester) async { await tester.pumpWidget(buildCard(acquisitionJob: _acquisitionJob(), screenSize: const Size(900, 800))); - expect(find.byIcon(Icons.more_vert), findsNothing); - await tester.pumpWidget(buildCard(screenSize: const Size(900, 800))); - expect(find.byIcon(Icons.more_vert), findsOneWidget); }); @@ -240,7 +241,6 @@ void main() { ); final status = tester.widget(find.text('Downloading 42%')); - expect(find.text('EPUB'), findsNothing); expect(status.maxLines, 1); expect(status.overflow, TextOverflow.ellipsis); @@ -257,7 +257,6 @@ void main() { ); final status = tester.widget(find.text('Adding to library')); - expect(find.text('EPUB'), findsNothing); expect(status.maxLines, 1); expect(status.overflow, TextOverflow.ellipsis); @@ -280,6 +279,7 @@ void main() { final finder = find.byKey(const ValueKey('linked-acquisition-book-card-job-1')); final node = tester.getSemantics(finder); + final interactiveNodes = find.semantics .byPredicate((candidate) => candidate.getSemanticsData().hasAction(SemanticsAction.tap)) .evaluate(); @@ -288,9 +288,7 @@ void main() { expect(node.flagsCollection.isSelected, Tristate.isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); expect(interactiveNodes, hasLength(1)); - tester.semantics.tap(find.semantics.byLabel(node.label)); - expect(selections, 1); } finally { semantics.dispose(); @@ -302,9 +300,7 @@ void main() { try { await tester.pumpWidget(buildCard(acquisitionJob: _acquisitionJob(), isSelected: true)); - final node = tester.getSemantics(find.byKey(const ValueKey('linked-acquisition-book-card-job-1'))); - expect(node.flagsCollection.isButton, isFalse); expect(node.flagsCollection.isSelected, Tristate.isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isFalse); @@ -331,7 +327,6 @@ void main() { tester.semantics.tap(find.semantics.byLabel('Select The Hobbit')); await tester.tap(find.byIcon(Icons.radio_button_unchecked)); - expect(selections, 2); expect(details, 0); } finally { @@ -351,12 +346,9 @@ void main() { final pointer = await tester.createGesture(kind: PointerDeviceKind.mouse); await pointer.addPointer(location: tester.getCenter(find.byType(BookCard))); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(AnimatedOpacity)).opacity, 1); - await pointer.moveTo(tester.getCenter(find.byKey(const ValueKey('acquisition-selector-job-1')))); await tester.pumpAndSettle(); - expect(tester.widget(find.byType(AnimatedOpacity)).opacity, 1); }); @@ -376,6 +368,7 @@ void main() { final finder = find.byKey(const ValueKey('linked-acquisition-book-card-job-1')); final node = tester.getSemantics(finder); + final interactiveNodes = find.semantics .byPredicate((candidate) => candidate.getSemanticsData().hasAction(SemanticsAction.tap)) .evaluate(); @@ -385,9 +378,7 @@ void main() { expect(node.flagsCollection.isButton, isTrue); expect(node.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); expect(interactiveNodes, hasLength(1)); - tester.semantics.tap(find.semantics.byLabel(node.label)); - expect(acquisitionTaps, 1); expect(favoriteTaps, 0); } finally { @@ -402,13 +393,11 @@ void main() { addTearDown(pointer.removePointer); await tester.pumpWidget(buildCard(onToggleFavorite: (_) => favoriteTaps++)); expect(find.byIcon(Icons.favorite_border), findsNothing); - await pointer.moveTo(tester.getCenter(find.byType(BookCard))); await tester.pumpAndSettle(); expect(find.byIcon(Icons.favorite_border), findsOneWidget); await tester.tap(find.byIcon(Icons.favorite_border)); expect(favoriteTaps, 1); - await pointer.moveTo(const Offset(790, 590)); await tester.pumpAndSettle(); expect(find.byIcon(Icons.favorite_border), findsNothing); @@ -445,17 +434,14 @@ void main() { expect(favoriteIcon, findsOneWidget); await tester.tap(favoriteIcon); await tester.pump(); - expect(tappedValue, false); }); testWidgets('calls onTap when card is tapped', (tester) async { var tapped = false; await tester.pumpWidget(buildCard(onTap: () => tapped = true)); - await tester.tap(find.byType(InkWell).first); await tester.pump(); - expect(tapped, true); }); @@ -466,7 +452,6 @@ void main() { testWidgets('forwards the book id to the cover renderer', (tester) async { await tester.pumpWidget(buildCard()); - expect(tester.widget(find.byType(CoverImage)).bookId, testBook.id); }); diff --git a/app/test/widgets/library/book_grid_test.dart b/app/test/widgets/library/book_grid_test.dart index dcc6c4c3..eed0b9a4 100644 --- a/app/test/widgets/library/book_grid_test.dart +++ b/app/test/widgets/library/book_grid_test.dart @@ -21,6 +21,7 @@ void main() { tester.view.physicalSize = const Size(1280, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -40,22 +41,21 @@ void main() { ), ), ); + final grid = tester.widget(find.byType(GridView)); expect((grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount).crossAxisCount, 2); expect(tester.takeException(), isNull); }); + testWidgets('renders books in a GridView', (tester) async { final books = [_book(id: 'book-1', title: 'First Book'), _book(id: 'book-2', title: 'Second Book')]; - await tester.pumpWidget(_buildGrid(books: books)); - expect(find.byType(GridView), findsOneWidget); expect(find.byType(BookCard), findsNWidgets(2)); }); testWidgets('renders an empty grid without items', (tester) async { await tester.pumpWidget(_buildGrid(books: const [])); - expect(find.byType(GridView), findsOneWidget); expect(find.byType(BookCard), findsNothing); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); @@ -65,9 +65,7 @@ void main() { group('BookGrid acquisition reconciliation', () { testWidgets('renders a job without a synchronized book once as an orphan', (tester) async { final orphan = _job(id: 'orphan-job', bookId: null, title: 'Waiting for Sync'); - await tester.pumpWidget(_buildGrid(books: const [], placeholderJobs: [orphan])); - expect(find.byType(BookCard), findsNothing); expect(find.byType(AcquisitionPlaceholderCard), findsOneWidget); expect(find.text('Waiting for Sync'), findsOneWidget); @@ -82,7 +80,6 @@ void main() { ); final card = tester.widget(_bookCard(book.id)); - expect(card.acquisitionJob, same(linked)); expect(find.byType(BookCard), findsOneWidget); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); @@ -91,9 +88,7 @@ void main() { testWidgets('defensively attaches a linked placeholder job by book id', (tester) async { final book = _book(id: 'book-1', title: 'Synchronized Book'); final linked = _job(id: 'linked-job', bookId: book.id, title: book.title); - await tester.pumpWidget(_buildGrid(books: [book], placeholderJobs: [linked])); - expect(tester.widget(_bookCard(book.id)).acquisitionJob, same(linked)); expect(find.byType(AcquisitionPlaceholderCard), findsNothing); }); @@ -118,17 +113,14 @@ void main() { final firstCard = tester.widget(_bookCard(firstBook.id)); final secondCard = tester.widget(_bookCard(secondBook.id)); - expect(firstCard.acquisitionJob, same(firstJob)); expect(firstCard.isSelectionMode, isTrue); expect(firstCard.isSelected, isTrue); expect(secondCard.acquisitionJob, isNull); expect(secondCard.isSelectionMode, isFalse); expect(secondCard.isSelected, isFalse); - await tester.tap(_bookCard(firstBook.id)); await tester.tap(_bookCard(secondBook.id)); - expect(acquisitionSelections, ['shared-job']); expect(ordinaryTaps, [secondBook.id]); }); @@ -156,9 +148,7 @@ void main() { final first = _job(id: 'job-1', bookId: 'pending-book', title: 'First'); final duplicateId = _job(id: 'job-1', bookId: null, title: 'Duplicate ID'); final duplicateBook = _job(id: 'job-2', bookId: 'pending-book', title: 'Duplicate Book'); - await tester.pumpWidget(_buildGrid(books: const [], placeholderJobs: [first, duplicateId, duplicateBook])); - expect(find.byType(AcquisitionPlaceholderCard), findsOneWidget); expect(find.text('First'), findsOneWidget); expect(find.text('Duplicate ID'), findsNothing); @@ -169,13 +159,10 @@ void main() { final first = _book(id: 'book-1', title: 'First Book'); final second = _book(id: 'book-2', title: 'Second Book'); final orphan = _job(id: 'orphan-job', bookId: null, title: 'Orphan Job'); - await tester.pumpWidget(_buildGrid(books: [first, second], placeholderJobs: [orphan])); - final firstPosition = tester.getTopLeft(_bookCard(first.id)); final secondPosition = tester.getTopLeft(_bookCard(second.id)); final orphanPosition = tester.getTopLeft(find.byType(AcquisitionPlaceholderCard)); - expect(firstPosition.dy, secondPosition.dy); expect(orphanPosition.dy, greaterThan(firstPosition.dy)); }); @@ -203,7 +190,6 @@ void main() { await tester.tap(find.byType(AcquisitionPlaceholderCard)); await tester.longPress(_bookCard(linkedBook.id)); await tester.longPress(find.byType(AcquisitionPlaceholderCard)); - expect(acquisitionTaps, ['job-linked', 'job-orphan']); expect(acquisitionToggles, ['job-linked', 'job-orphan']); expect(bookTaps, isEmpty); @@ -229,13 +215,10 @@ void main() { final linkedCard = tester.widget(_bookCard(linkedBook.id)); final orphanCard = tester.widget(find.byType(AcquisitionPlaceholderCard)); - expect(linkedCard.isSelected, isTrue); expect(orphanCard.isSelected, isFalse); - await tester.tap(_bookCard(linkedBook.id)); await tester.tap(find.byType(AcquisitionPlaceholderCard)); - expect(acquisitionToggles, ['job-linked', 'job-orphan']); expect(acquisitionTaps, isEmpty); }); @@ -243,12 +226,9 @@ void main() { testWidgets('does not open ordinary book actions for a linked job without callbacks', (tester) async { final linkedBook = _book(id: 'book-linked', title: 'Linked Book'); final linkedJob = _job(id: 'job-linked', bookId: linkedBook.id, title: linkedBook.title); - await tester.pumpWidget(_buildGrid(books: [linkedBook], acquisitionJobsByBookId: {linkedBook.id: linkedJob})); - await tester.longPress(_bookCard(linkedBook.id)); await tester.pumpAndSettle(); - expect(find.text('Select'), findsNothing); expect(tester.takeException(), isNull); }); @@ -269,7 +249,6 @@ void main() { ); await tester.tap(_bookCard(linkedBook.id)); - expect(ordinaryTaps, isEmpty); expect(tester.takeException(), isNull); }); @@ -285,7 +264,6 @@ void main() { final linkedCard = tester.widget(_bookCard(linkedBook.id)); final ordinaryCard = tester.widget(_bookCard(ordinaryBook.id)); - expect(linkedCard.onToggleFavorite, isNull); expect(ordinaryCard.onToggleFavorite, isNotNull); }); @@ -306,12 +284,9 @@ void main() { ); await tester.tap(_bookCard(ordinary.id)); - expect(tappedBooks, [ordinary.id]); - tester.widget(_bookCard(ordinary.id)).onEnterSelectionMode!.call(); await tester.pump(); - expect(provider.isBookSelected(ordinary.id), isTrue); }); @@ -331,7 +306,6 @@ void main() { final ordinaryCard = tester.widget(_bookCard(ordinary.id)); final unselectedLinkedCard = tester.widget(_bookCard(linkedBook.id)); - expect(ordinaryCard.isSelectionMode, isTrue); expect(ordinaryCard.isSelected, isTrue); expect(unselectedLinkedCard.isSelectionMode, isFalse); @@ -347,7 +321,6 @@ void main() { ); final selectedLinkedCard = tester.widget(_bookCard(linkedBook.id)); - expect(selectedLinkedCard.isSelectionMode, isTrue); expect(selectedLinkedCard.isSelected, isTrue); expect(provider.isBookSelected(linkedBook.id), isFalse); @@ -362,6 +335,7 @@ void main() { addTearDown(tester.view.reset); final provider = LibraryProvider()..setGridItemWidth(BookGridSize.minimum); addTearDown(provider.dispose); + await tester.pumpWidget( _buildGrid( books: [_book(id: 'book-1', title: 'A book')], @@ -370,29 +344,40 @@ void main() { showViewControls: true, ), ); + await tester.tap(find.text('View mode')); await tester.pumpAndSettle(); expect(find.byType(Slider), findsNothing); final options = bookGridSizeOptions(width - 32); - if (width < 600) expect(options.map((option) => option.columns), [1, 2, 3, 4]); + + if (width < 600) { + expect(options.map((option) => option.columns), [1, 2, 3, 4]); + } + final columnChoices = find.byWidgetPredicate( (widget) => widget is ChoiceChip && widget.label is Text && (widget.label as Text).data!.contains('column'), ); + expect(columnChoices, findsNWidgets(options.length)); + for (final option in options) { final label = '${option.columns} ${option.columns == 1 ? 'column' : 'columns'}'; await tester.tap(find.text(label)); await tester.pumpAndSettle(); + final delegate = tester.widget(find.byType(GridView)).gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, option.columns); expect(tester.widget(find.widgetWithText(ChoiceChip, label)).selected, isTrue); final layout = bookGridLayout(width - 32, itemWidth: provider.gridItemWidth); + expect( tester.getSize(_bookCard('book-1')).width * option.columns + layout.spacing * (option.columns - 1), closeTo(width - 32, .001), ); } + expect(tester.takeException(), isNull); }); } @@ -411,6 +396,7 @@ void main() { addTearDown(tester.view.reset); final provider = LibraryProvider()..setGridItemWidth(BookGridSize.minimum); addTearDown(provider.dispose); + await tester.pumpWidget( _buildGrid( books: [_book(id: 'book-1', title: 'A long book title that uses two lines')], @@ -420,34 +406,43 @@ void main() { textScaler: TextScaler.linear(scale), ), ); + Size? previous; Size? previousCover; + for (final option in bookGridSizeOptions(width - 32).reversed) { provider.setGridItemWidth(option.preferredWidth); await tester.pumpAndSettle(); final actual = tester.getSize(_bookCard('book-1')); expect(tester.widget(_bookCard('book-1')).compact, actual.width < BookGridSize.regularMinimum); + expect( tester.widget(find.byType(AcquisitionPlaceholderCard)).compact, actual.width < BookGridSize.regularMinimum, ); + final cover = tester.getSize(find.descendant(of: _bookCard('book-1'), matching: find.byType(CoverImage))); + if (previous != null) { expect(actual.width, greaterThan(previous.width), reason: '${option.columns} columns'); expect(actual.height, greaterThan(previous.height), reason: '${option.columns} columns'); } + if (previousCover != null) { expect(cover.width, greaterThan(previousCover.width), reason: '${option.columns} columns'); + expect( cover.width * cover.height, greaterThan(previousCover.width * previousCover.height), reason: '${option.columns} columns', ); } + expect(actual.width, lessThanOrEqualTo(width - 32)); final layout = bookGridLayout(width - 32, itemWidth: provider.gridItemWidth); expect(actual.width * option.columns + layout.spacing * (option.columns - 1), closeTo(width - 32, .001)); expect(tester.getTopLeft(_bookCard('book-1')).dx, 16); + if (option.columns > 1) { expect( tester.getTopLeft(find.byType(AcquisitionPlaceholderCard)).dx - @@ -455,6 +450,7 @@ void main() { closeTo(layout.spacing, .001), ); } + expect(tester.getSize(find.byType(AcquisitionPlaceholderCard)), actual); expect(tester.takeException(), isNull); previous = actual; @@ -495,12 +491,11 @@ void main() { for (final (width, columns) in const [(400.0, 2), (700.0, 3), (900.0, 5), (1300.0, 7)]) { tester.view.physicalSize = Size(width, 800); await tester.pumpWidget(_buildGrid(books: [book], placeholderJobs: [orphan], screenSize: Size(width, 800))); - final grid = tester.widget(find.byType(GridView)); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, columns); expect(tester.getSize(_bookCard(book.id)).width, tester.getSize(find.byType(AcquisitionPlaceholderCard)).width); + expect( tester.getSize(_bookCard(book.id)).height, tester.getSize(find.byType(AcquisitionPlaceholderCard)).height, diff --git a/app/test/widgets/library/book_list_item_test.dart b/app/test/widgets/library/book_list_item_test.dart index 72b8f76e..4538f9d8 100644 --- a/app/test/widgets/library/book_list_item_test.dart +++ b/app/test/widgets/library/book_list_item_test.dart @@ -104,10 +104,8 @@ void main() { testWidgets('calls onTap when tapped', (tester) async { var tapped = false; await tester.pumpWidget(buildListItem(onTap: () => tapped = true)); - await tester.tap(find.byType(InkWell).first); await tester.pump(); - expect(tapped, true); }); @@ -125,14 +123,12 @@ void main() { await tester.tap(find.byType(BookListItem)); await tester.longPress(find.byType(BookListItem)); - expect(ordinaryTaps, 0); expect(ordinarySelections, 0); }); testWidgets('linked job does not expose the ordinary desktop context menu', (tester) async { await tester.pumpWidget(buildListItem(acquisitionJob: _acquisitionJob(), screenSize: const Size(900, 800))); - expect(find.byTooltip('More options'), findsNothing); }); @@ -154,7 +150,6 @@ void main() { await tester.tap(find.byType(BookListItem)); await tester.longPress(find.byType(BookListItem)); - expect(acquisitionTaps, 1); expect(acquisitionSelections, 1); expect(ordinaryTaps, 0); @@ -176,9 +171,7 @@ void main() { ); expect(find.byType(Checkbox), findsNothing); - await tester.tap(find.byType(BookListItem)); - expect(acquisitionTaps, 1); expect(acquisitionSelections, 0); @@ -195,9 +188,7 @@ void main() { ); expect(tester.widget(find.byType(Checkbox)).value, isTrue); - await tester.tap(find.byType(BookListItem)); - expect(acquisitionTaps, 1); expect(acquisitionSelections, 1); }); @@ -216,7 +207,6 @@ void main() { ); await tester.tap(find.byType(BookListItem)); - expect(ordinaryTaps, 0); expect(ordinarySelections, 1); }); @@ -228,7 +218,6 @@ void main() { testWidgets('forwards the book id to the cover renderer', (tester) async { await tester.pumpWidget(buildListItem()); - expect(tester.widget(find.byType(CoverImage)).bookId, testBook.id); }); diff --git a/app/test/widgets/library/library_drawer_test.dart b/app/test/widgets/library/library_drawer_test.dart index c35e667b..c4507dc7 100644 --- a/app/test/widgets/library/library_drawer_test.dart +++ b/app/test/widgets/library/library_drawer_test.dart @@ -20,7 +20,6 @@ void main() { await tester.pumpWidget(buildDrawer()); await tester.tap(find.text('Open Drawer')); await tester.pumpAndSettle(); - expect(find.text('Library'), findsOneWidget); }); @@ -28,7 +27,6 @@ void main() { await tester.pumpWidget(buildDrawer()); await tester.tap(find.text('Open Drawer')); await tester.pumpAndSettle(); - expect(find.text('Books'), findsOneWidget); expect(find.text('Shelves'), findsOneWidget); expect(find.text('Bookmarks'), findsOneWidget); @@ -40,7 +38,6 @@ void main() { await tester.pumpWidget(buildDrawer()); await tester.tap(find.text('Open Drawer')); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.book), findsOneWidget); expect(find.byIcon(Icons.shelves), findsOneWidget); expect(find.byIcon(Icons.bookmark), findsOneWidget); @@ -62,7 +59,6 @@ void main() { await tester.pumpWidget(buildDrawer(currentPath: '/library/books')); await tester.tap(find.text('Open Drawer')); await tester.pumpAndSettle(); - final booksItem = tester.widget(find.ancestor(of: find.text('Books'), matching: find.byType(ListTile))); expect(booksItem.selected, true); }); @@ -75,6 +71,7 @@ void main() { final shelvesItem = tester.widget( find.ancestor(of: find.text('Shelves'), matching: find.byType(ListTile)), ); + expect(shelvesItem.selected, true); }); @@ -86,8 +83,8 @@ void main() { final shelvesItem = tester.widget( find.ancestor(of: find.text('Shelves'), matching: find.byType(ListTile)), ); - expect(shelvesItem.selected, false); + expect(shelvesItem.selected, false); final notesItem = tester.widget(find.ancestor(of: find.text('Notes'), matching: find.byType(ListTile))); expect(notesItem.selected, false); }); @@ -96,7 +93,6 @@ void main() { await tester.pumpWidget(buildDrawer()); await tester.tap(find.text('Open Drawer')); await tester.pumpAndSettle(); - expect(find.byType(Drawer), findsOneWidget); }); }); diff --git a/app/test/widgets/library/library_filter_chips_test.dart b/app/test/widgets/library/library_filter_chips_test.dart index bb02bf16..2a5f8cff 100644 --- a/app/test/widgets/library/library_filter_chips_test.dart +++ b/app/test/widgets/library/library_filter_chips_test.dart @@ -41,7 +41,6 @@ void main() { testWidgets('renders the current filter, sort, and view categories', (tester) async { await pumpChips(tester); - expect(find.text('Status'), findsOneWidget); expect(find.text(LibrarySortOption.dateAddedNewest.label), findsOneWidget); expect(find.text('Favorites'), findsOneWidget); @@ -55,8 +54,8 @@ void main() { testWidgets('uses a horizontal list with fixed height', (tester) async { await pumpChips(tester); - final listView = tester.widget(find.byType(ListView)); + final container = tester.widget( find.ancestor(of: find.byType(AnimatedSwitcher), matching: find.byType(SizedBox)).first, ); @@ -67,23 +66,18 @@ void main() { testWidgets('shows active multi-select categories first with a count label', (tester) async { libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress, LibraryReadingStatus.completed}); - await pumpChips(tester); - expect(find.text('Status · 2'), findsOneWidget); expect(libraryProvider.activeFilterCount, 1); }); testWidgets('favorite selection sheet updates the shared filter model', (tester) async { await pumpChips(tester); - await tester.tap(find.text('Favorites')); await tester.pumpAndSettle(); expect(find.text('Favorite state'), findsOneWidget); - await tester.tap(find.text('Favorites').last); await tester.pumpAndSettle(); - expect(libraryProvider.favoriteFilter, FavoriteFilter.favorites); }); @@ -94,6 +88,7 @@ void main() { addTearDown(tester.view.reset); final store = DataStore()..loadData(books: createTestBooks().take(2).toList()); addTearDown(store.dispose); + await tester.pumpWidget( createTestApp( libraryProvider: libraryProvider, @@ -102,6 +97,7 @@ void main() { child: const LibraryFilterChips(), ), ); + await tester.scrollUntilVisible(find.text(category), 150, scrollable: find.byType(Scrollable)); await Scrollable.ensureVisible(tester.element(find.text(category)), alignment: .5); await tester.pumpAndSettle(); @@ -119,14 +115,11 @@ void main() { testWidgets('view selection sheet updates the shared view mode', (tester) async { await pumpChips(tester); - await tester.tap(find.text(LibraryViewMode.grid.label)); await tester.pumpAndSettle(); expect(find.text('View mode'), findsOneWidget); - await tester.tap(find.text(LibraryViewMode.list.label).last); await tester.pumpAndSettle(); - expect(libraryProvider.viewMode, LibraryViewMode.list); }); @@ -158,6 +151,7 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = Size(layout.screenWidth, 1000); addTearDown(tester.view.reset); + await tester.pumpWidget( createTestApp( libraryProvider: libraryProvider, @@ -174,6 +168,7 @@ void main() { ), ), ); + await tester.scrollUntilVisible(find.text('Grid'), 300, scrollable: find.byType(Scrollable)); await Scrollable.ensureVisible(tester.element(find.byTooltip('Change view mode')), alignment: .5); await tester.pumpAndSettle(); @@ -181,12 +176,15 @@ void main() { await tester.pumpAndSettle(); expect(find.text('View mode'), findsOneWidget); final options = bookGridSizeOptions(layout.contentWidth - 2 * layout.padding); + final choices = tester .widgetList(find.byType(ChoiceChip)) .where((chip) => (chip.label as Text).data!.contains('column')); + expect(choices.map((chip) => (chip.label as Text).data), [ for (final option in options) '${option.columns} ${option.columns == 1 ? 'column' : 'columns'}', ]); + expect(tester.takeException(), isNull); }); } @@ -195,11 +193,9 @@ void main() { libraryProvider.setStatusFilters({LibraryReadingStatus.inProgress}); libraryProvider.setSortOption(LibrarySortOption.titleAZ); libraryProvider.setViewMode(LibraryViewMode.list); - await pumpChips(tester); await tester.tap(find.text('Clear all')); await tester.pumpAndSettle(); - expect(libraryProvider.filters.isEmpty, isTrue); expect(libraryProvider.sortOption, LibrarySortOption.dateAddedNewest); expect(libraryProvider.viewMode, LibraryViewMode.grid); diff --git a/app/test/widgets/library/online_books_header_test.dart b/app/test/widgets/library/online_books_header_test.dart index e31a3173..2ba76d07 100644 --- a/app/test/widgets/library/online_books_header_test.dart +++ b/app/test/widgets/library/online_books_header_test.dart @@ -40,18 +40,14 @@ void main() { final controller = TextEditingController(); addTearDown(controller.dispose); var backCalls = 0; - await tester.pumpWidget(buildHeader(controller: controller, onBack: () => backCalls++, onSearch: (_) {})); - expect(find.byTooltip('Back'), findsOneWidget); expect(find.bySemanticsLabel('Back'), findsOneWidget); expect(find.text('Online results'), findsOneWidget); expect(find.byType(TextField), findsOneWidget); expect(find.byTooltip('Search'), findsOneWidget); expect(find.bySemanticsLabel('Search'), findsOneWidget); - await tester.tap(find.byTooltip('Back')); - expect(backCalls, 1); }); @@ -84,7 +80,6 @@ void main() { final onlineField = find.descendant(of: find.byType(OnlineBooksHeader), matching: find.byType(TextField)); final libraryField = find.descendant(of: find.byType(LibrarySearchBar), matching: find.byType(TextField)); - expect(tester.getSize(onlineField).height, tester.getSize(libraryField).height); }); @@ -132,10 +127,8 @@ void main() { testWidgets('honors autofocus for online search entered from add book', (tester) async { final controller = TextEditingController(); addTearDown(controller.dispose); - await tester.pumpWidget(buildHeader(controller: controller, autofocus: true, onBack: () {}, onSearch: (_) {})); await tester.pump(); - expect(tester.widget(find.byType(EditableText)).focusNode.hasFocus, isTrue); }); @@ -143,20 +136,14 @@ void main() { final controller = TextEditingController(); addTearDown(controller.dispose); final submitted = []; - await tester.pumpWidget(buildHeader(controller: controller, onBack: () {}, onSearch: submitted.add)); - expect(searchButton(tester).onPressed, isNull); - await tester.enterText(find.byType(TextField), 'Dune'); await tester.pump(); - expect(submitted, isEmpty); expect(searchButton(tester).onPressed, isNotNull); - await tester.enterText(find.byType(TextField), ' '); await tester.pump(); - expect(searchButton(tester).onPressed, isNull); }); @@ -164,12 +151,9 @@ void main() { final controller = TextEditingController(); addTearDown(controller.dispose); final submitted = []; - await tester.pumpWidget(buildHeader(controller: controller, onBack: () {}, onSearch: submitted.add)); - await tester.enterText(find.byType(TextField), ' The Left Hand of Darkness '); await tester.testTextInput.receiveAction(TextInputAction.search); - expect(submitted, ['The Left Hand of Darkness']); }); @@ -177,22 +161,17 @@ void main() { final controller = TextEditingController(); addTearDown(controller.dispose); final submitted = []; - await tester.pumpWidget(buildHeader(controller: controller, onBack: () {}, onSearch: submitted.add)); - await tester.enterText(find.byType(TextField), ' A Wizard of Earthsea '); await tester.pump(); await tester.tap(find.byTooltip('Search')); - expect(submitted, ['A Wizard of Earthsea']); }); testWidgets('disables submit while searching', (tester) async { final controller = TextEditingController(text: 'Dune'); addTearDown(controller.dispose); - await tester.pumpWidget(buildHeader(controller: controller, isSearching: true, onBack: () {}, onSearch: (_) {})); - expect(searchButton(tester).onPressed, isNull); }); @@ -204,10 +183,8 @@ void main() { for (final size in [const Size(320, 640), const Size(1280, 800)]) { tester.view.physicalSize = size; - await tester.pumpWidget(buildHeader(controller: controller, onBack: () {}, onSearch: (_) {})); await tester.pump(); - expect(tester.takeException(), isNull); } }); @@ -225,10 +202,9 @@ void main() { child: buildHeader(controller: controller, onBack: () {}, onSearch: (_) {}), ), ); - await tester.pump(); + await tester.pump(); final headerRow = find.ancestor(of: find.text('Online results'), matching: find.byType(Row)); - expect(headerRow, findsOneWidget); expect(find.descendant(of: headerRow, matching: find.byType(TextField)), findsOneWidget); expect(tester.takeException(), isNull); diff --git a/app/test/widgets/library/online_results_view_test.dart b/app/test/widgets/library/online_results_view_test.dart index 7d9afbd9..a5cf3c25 100644 --- a/app/test/widgets/library/online_results_view_test.dart +++ b/app/test/widgets/library/online_results_view_test.dart @@ -44,7 +44,6 @@ void main() { testWidgets('shows guidance before a search', (tester) async { await tester.pumpWidget(buildView()); - expect(find.text('Search connected sources'), findsOneWidget); expect(find.text('Search by title or author to find available releases.'), findsOneWidget); expect(find.byType(RemoteReleaseList), findsNothing); @@ -52,7 +51,6 @@ void main() { testWidgets('shows query-aware loading before stale results', (tester) async { await tester.pumpWidget(buildView(isSearching: true, query: 'Dune', releases: const [release])); - expect(find.byType(CircularProgressIndicator), findsOneWidget); expect(find.text('Searching connected sources for “Dune”…'), findsOneWidget); expect(find.text(release.title), findsNothing); @@ -73,15 +71,12 @@ void main() { expect(retryCalls, 0); expect(find.text('The connected sources are unavailable.'), findsOneWidget); expect(find.text(release.title), findsNothing); - await tester.tap(find.text('Try again')); - expect(retryCalls, 1); }); testWidgets('shows query-aware empty guidance after a search', (tester) async { await tester.pumpWidget(buildView(hasSearched: true, query: 'Dune')); - expect(find.text('No releases found'), findsOneWidget); expect(find.text('No releases found for “Dune”. Try another title or author.'), findsOneWidget); }); @@ -102,9 +97,7 @@ void main() { expect(find.byType(RemoteReleaseList), findsOneWidget); expect(find.text(release.title), findsOneWidget); expect(find.text('The download client rejected this release.'), findsOneWidget); - await tester.tap(find.text(release.title)); - expect(toggled, ['token-1']); }); } diff --git a/app/test/widgets/library/remote_release_list_test.dart b/app/test/widgets/library/remote_release_list_test.dart index df2b224c..0da90948 100644 --- a/app/test/widgets/library/remote_release_list_test.dart +++ b/app/test/widgets/library/remote_release_list_test.dart @@ -16,7 +16,9 @@ void main() { sizeBytes: 1572864, seeders: 12, ); + const submissionError = 'The download client rejected this release.'; + const semanticLabel = 'The Left Hand of Darkness EPUB. Nyaa. EPUB, RETAIL. 1.5 MB. 12 seeders. ' 'The download client rejected this release.'; @@ -46,7 +48,6 @@ void main() { testWidgets('shows release details across the full-width row', (tester) async { await tester.pumpWidget(buildList()); - expect(find.byType(Checkbox), findsOneWidget); expect(find.text(release.title), findsOneWidget); expect(find.text('Nyaa · EPUB, RETAIL · 1.5 MB · 12 seeders'), findsOneWidget); @@ -55,13 +56,13 @@ void main() { testWidgets('uses the selected color scheme treatment', (tester) async { await tester.pumpWidget(buildList(selectedReleaseTokens: const {'token-1'})); - final colorScheme = AppTheme.dark.colorScheme; expect( tester.widgetList(find.byType(Material)).map((material) => material.color), contains(colorScheme.primaryContainer.withValues(alpha: 0.35)), ); + expect(tester.widget(find.byType(Checkbox)).value, isTrue); }); @@ -72,12 +73,12 @@ void main() { protocol: 'torrent', indexer: 'BookBay', ); + await tester.pumpWidget( buildList(releases: const [release, otherRelease], errorsByReleaseToken: const {'token-1': submissionError}), ); final errorText = tester.widget(find.text(submissionError)); - expect(errorText.style?.fontSize, AppTheme.dark.textTheme.bodySmall?.fontSize); expect(errorText.style?.color, AppTheme.dark.colorScheme.error); expect(find.text(otherRelease.title), findsOneWidget); @@ -86,19 +87,15 @@ void main() { testWidgets('row tap toggles its release exactly once', (tester) async { final toggled = []; - await tester.pumpWidget(buildList(onToggleSelection: toggled.add)); await tester.tap(find.text(release.title)); - expect(toggled, ['token-1']); }); testWidgets('checkbox toggle changes its release exactly once', (tester) async { final toggled = []; - await tester.pumpWidget(buildList(onToggleSelection: toggled.add)); await tester.tap(find.byType(Checkbox)); - expect(toggled, ['token-1']); }); @@ -112,12 +109,12 @@ void main() { final releaseNodes = find.semantics.byLabel(semanticLabel).evaluate().toList(); final releaseSemantics = tester.getSemantics(find.byKey(const ValueKey('remote-release-token-1'))); - expect(releaseNodes, hasLength(1)); expect(releaseSemantics.label, semanticLabel); expect(releaseSemantics.flagsCollection.isButton, isTrue); expect(releaseSemantics.flagsCollection.isSelected, Tristate.isTrue); expect(releaseSemantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); + final unlabeledInteractiveNodes = find.semantics .byPredicate( (node) => @@ -139,10 +136,9 @@ void main() { try { await tester.pumpWidget(buildList(onToggleSelection: toggled.add)); - final releaseSemantics = tester.getSemantics(find.byKey(const ValueKey('remote-release-token-1'))); - expect(releaseSemantics.flagsCollection.isSelected, Tristate.isFalse); + tester.semantics.tap( find.semantics.byLabel('The Left Hand of Darkness EPUB. Nyaa. EPUB, RETAIL. 1.5 MB. 12 seeders'), ); diff --git a/app/test/widgets/persistent_editor_test.dart b/app/test/widgets/persistent_editor_test.dart index 60002c55..b68671af 100644 --- a/app/test/widgets/persistent_editor_test.dart +++ b/app/test/widgets/persistent_editor_test.dart @@ -20,6 +20,7 @@ void main() { ) async { final write = Completer(); var calls = 0; + Future save() { calls++; return write.future; @@ -63,15 +64,20 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); + if (kind == 'shelf' || kind == 'topic' || kind == 'note') { await tester.enterText(find.byType(TextFormField).first, 'Name'); } + if (kind == 'note') { await tester.enterText(find.byKey(const Key('note-content-field')), 'Content'); } + await tester.pump(); + final button = find.widgetWithText( FilledButton, kind == 'shelf' @@ -80,6 +86,7 @@ void main() { ? 'Create topic' : 'Save', ); + await tester.ensureVisible(button); await tester.tap(button); await tester.pump(); @@ -92,8 +99,10 @@ void main() { } else { write.complete(); } + await tester.pumpAndSettle(); expect(button, fails ? findsOneWidget : findsNothing); + if (fails) { expect(find.text('Could not save changes. Please try again.'), findsOneWidget); expect(tester.widget(button).onPressed, isNotNull); diff --git a/app/test/widgets/profile/appearance_settings_section_test.dart b/app/test/widgets/profile/appearance_settings_section_test.dart index e4c7d13e..befe7d66 100644 --- a/app/test/widgets/profile/appearance_settings_section_test.dart +++ b/app/test/widgets/profile/appearance_settings_section_test.dart @@ -26,36 +26,28 @@ void main() { testWidgets('renders mobile appearance settings and opens theme picker', (tester) async { await tester.pumpWidget(buildSection(isDesktop: false)); - expect(find.text('Appearance'), findsOneWidget); expect(find.text('Theme'), findsOneWidget); expect(find.text('Light'), findsOneWidget); - await tester.tap(find.text('Theme')); await tester.pumpAndSettle(); - expect(find.text('Dark'), findsOneWidget); expect(find.text('E-ink'), findsOneWidget); expect(find.text('System'), findsOneWidget); - await tester.tap(find.text('Dark')); await tester.pumpAndSettle(); - expect(prefsProvider.themeModePref, equals('dark')); }); testWidgets('renders desktop radio tile group and updates theme preference', (tester) async { await tester.pumpWidget(buildSection(isDesktop: true)); - expect(find.text('Theme'), findsOneWidget); expect(find.text('Light'), findsOneWidget); expect(find.text('Dark'), findsOneWidget); expect(find.text('E-ink'), findsOneWidget); expect(find.text('System default'), findsOneWidget); - await tester.tap(find.text('Dark')); await tester.pumpAndSettle(); - expect(prefsProvider.themeModePref, equals('dark')); }); }); diff --git a/app/test/widgets/profile/library_settings_section_test.dart b/app/test/widgets/profile/library_settings_section_test.dart index 412f961d..2010a3df 100644 --- a/app/test/widgets/profile/library_settings_section_test.dart +++ b/app/test/widgets/profile/library_settings_section_test.dart @@ -16,6 +16,7 @@ void main() { 'metadata_source': 'Open Library', 'annotation_export_format': 'Markdown', }); + final prefs = await SharedPreferences.getInstance(); prefsProvider = PreferencesProvider(prefs); }); @@ -31,7 +32,6 @@ void main() { testWidgets('renders mobile library settings and rows', (tester) async { await tester.pumpWidget(buildSection(isDesktop: false)); - expect(find.text('Library'), findsOneWidget); expect(find.text('Default view'), findsOneWidget); expect(find.text('Default sort'), findsOneWidget); @@ -41,7 +41,6 @@ void main() { testWidgets('renders desktop display and data configuration cards', (tester) async { await tester.pumpWidget(buildSection(isDesktop: true)); - expect(find.text('Display'), findsOneWidget); expect(find.text('Default view mode'), findsOneWidget); expect(find.text('Default sort order'), findsOneWidget); diff --git a/app/test/widgets/profile/reading_settings_section_test.dart b/app/test/widgets/profile/reading_settings_section_test.dart index 9e547c6a..903ba67f 100644 --- a/app/test/widgets/profile/reading_settings_section_test.dart +++ b/app/test/widgets/profile/reading_settings_section_test.dart @@ -16,6 +16,7 @@ void main() { 'reading_mode': 'paginated', 'page_turn_animation': true, }); + final prefs = await SharedPreferences.getInstance(); prefsProvider = PreferencesProvider(prefs); }); @@ -33,24 +34,20 @@ void main() { testWidgets('renders mobile reading options and allows toggling page animation', (tester) async { await tester.pumpWidget(buildSection(isDesktop: false)); - expect(find.text('Reading'), findsOneWidget); expect(find.text('Default font'), findsOneWidget); expect(find.text('Line spacing'), findsOneWidget); expect(find.text('Reading mode'), findsOneWidget); expect(find.text('Page turn animation'), findsOneWidget); - final switchFinder = find.byType(Switch); expect(switchFinder, findsOneWidget); await tester.tap(switchFinder); await tester.pumpAndSettle(); - expect(prefsProvider.pageTurnAnimation, isFalse); }); testWidgets('renders desktop reading typography and behavior options', (tester) async { await tester.pumpWidget(buildSection(isDesktop: true)); - expect(find.text('Typography'), findsOneWidget); expect(find.text('Default font size'), findsOneWidget); expect(find.text('Behavior'), findsOneWidget); diff --git a/app/test/widgets/profile/storage_sync_settings_section_test.dart b/app/test/widgets/profile/storage_sync_settings_section_test.dart index 1db0b6df..7cf605fa 100644 --- a/app/test/widgets/profile/storage_sync_settings_section_test.dart +++ b/app/test/widgets/profile/storage_sync_settings_section_test.dart @@ -20,10 +20,13 @@ import 'package:shared_preferences/shared_preferences.dart'; class _MemoryStorage implements RefreshTokenStorage { String? token; + @override Future delete() async => token = null; + @override Future read() async => token; + @override Future write(String val) async => token = val; } @@ -42,6 +45,7 @@ class _MockAuthRepo extends AuthRepository { class _OfflineConnector extends PowerSyncBackendConnector { @override Future fetchCredentials() async => null; + @override Future uploadData(PowerSyncDatabase database) async {} } @@ -77,6 +81,7 @@ void main() { await authProvider.bootstrap(); authProvider.setOfflineMode(true); powerSync = _MockPowerSyncService(); + syncSettings = SyncSettingsProvider( prefs, officialConfig: PapyrusApiConfig( @@ -84,6 +89,7 @@ void main() { powerSyncServiceUri: Uri.parse('https://data-sync.test'), ), ); + dataStore = DataStore(); uploadQueue = MediaUploadQueue(prefs); prefsProvider = PreferencesProvider(prefs); @@ -112,7 +118,6 @@ void main() { testWidgets('renders desktop offline storage card and actions', (tester) async { await tester.pumpWidget(buildSection(isDesktop: true)); - expect(find.text('Library storage'), findsOneWidget); expect(find.text('Your library is stored on this device.'), findsOneWidget); expect(find.text('Export backup'), findsOneWidget); @@ -122,7 +127,6 @@ void main() { testWidgets('renders mobile storage settings rows', (tester) async { await tester.pumpWidget(buildSection(isDesktop: false)); - expect(find.text('Storage'), findsOneWidget); expect(find.text('Library'), findsOneWidget); expect(find.text('Stored on this device'), findsOneWidget); diff --git a/app/test/widgets/search/library_search_bar_test.dart b/app/test/widgets/search/library_search_bar_test.dart index ecd12f50..94854026 100644 --- a/app/test/widgets/search/library_search_bar_test.dart +++ b/app/test/widgets/search/library_search_bar_test.dart @@ -43,26 +43,21 @@ void main() { testWidgets('calls onQueryChanged when text is entered', (tester) async { String? lastQuery; await tester.pumpWidget(buildSearchBar(onQueryChanged: (q) => lastQuery = q)); - await tester.enterText(find.byType(TextField), 'tolkien'); await tester.pump(); - expect(lastQuery, 'tolkien'); }); testWidgets('calls onFilterTap when filter button is tapped', (tester) async { var filterTapped = false; await tester.pumpWidget(buildSearchBar(onFilterTap: () => filterTapped = true)); - await tester.tap(find.byIcon(Icons.tune_rounded)); await tester.pump(); - expect(filterTapped, true); }); testWidgets('shows filter badge when activeFilterCount > 0', (tester) async { await tester.pumpWidget(buildSearchBar(activeFilterCount: 3, onFilterTap: () {})); - expect(find.text('3'), findsOneWidget); }); @@ -76,7 +71,6 @@ void main() { testWidgets('shows clear button when text is entered', (tester) async { await tester.pumpWidget(buildSearchBar(initialQuery: 'test')); await tester.pump(); - expect(find.byIcon(Icons.clear), findsOneWidget); }); @@ -84,17 +78,14 @@ void main() { String? lastQuery; await tester.pumpWidget(buildSearchBar(initialQuery: 'test', onQueryChanged: (q) => lastQuery = q)); await tester.pump(); - await tester.tap(find.byIcon(Icons.clear)); await tester.pump(); - expect(lastQuery, ''); }); testWidgets('initializes with initial query', (tester) async { await tester.pumpWidget(buildSearchBar(initialQuery: 'my search')); await tester.pump(); - expect(find.text('my search'), findsOneWidget); }); @@ -111,7 +102,6 @@ void main() { // Rebuild with new initialQuery await tester.pumpWidget(buildSearchBar(initialQuery: 'new query')); await tester.pump(); - expect(find.text('new query'), findsOneWidget); }); }); diff --git a/app/test/widgets/shared/bottom_sheet_expansion_test.dart b/app/test/widgets/shared/bottom_sheet_expansion_test.dart index 905492ea..482211bc 100644 --- a/app/test/widgets/shared/bottom_sheet_expansion_test.dart +++ b/app/test/widgets/shared/bottom_sheet_expansion_test.dart @@ -36,6 +36,7 @@ void main() { addTearDown(tester.view.reset); final dataStore = store ?? (DataStore()..loadData(shelves: const [], tags: const [])); addTearDown(dataStore.dispose); + await tester.pumpWidget( ChangeNotifierProvider.value( value: dataStore, @@ -49,6 +50,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -60,6 +62,7 @@ void main() { addTearDown(library.dispose); final store = DataStore()..loadData(books: createTestBooks()); LibraryFilters? result; + await openSheet( tester, (context) async { @@ -68,13 +71,16 @@ void main() { store: store, size: size, ); + expect(find.text('Reset'), findsOneWidget); expect(find.text('Cancel'), findsNothing); expect(find.text('Show 2 books'), findsOneWidget); + final reset = find.descendant( of: find.byType(BottomSheetFooter), matching: find.widgetWithText(OutlinedButton, 'Reset'), ); + await tester.tap(reset); await tester.pumpAndSettle(); expect(find.byType(LibraryAdvancedFilterSheet), findsOneWidget); @@ -100,6 +106,7 @@ void main() { LibraryAdvancedFilterSheet.show(context, libraryProvider: library, dataStore: context.read()); }, }; + for (final entry in longSheets.entries) { testWidgets('${entry.key} expands before scrolling on mobile', (tester) async { final store = DataStore() @@ -108,18 +115,23 @@ void main() { shelves: List.generate(20, (i) => buildTestShelf(id: 'shelf-$i', name: 'Shelf $i')), tags: List.generate(20, (i) => buildTestTag(id: 'topic-$i', name: 'Topic $i')), ); + await openSheet(tester, entry.value, store: store); expect(find.byType(ExpandableBottomSheet), findsOneWidget); final header = find.byKey(Key(entry.key == 'physical book' ? 'add-book-sheet-header' : 'bottom-sheet-header')); final initialTop = tester.getTopLeft(header).dy; expect(initialTop, greaterThan(24)); + final scrollable = find .descendant(of: find.byType(ExpandableBottomSheet), matching: find.byType(Scrollable)) .first; + final position = tester.state(scrollable).position; + final footer = entry.key == 'physical book' ? find.byKey(const Key('add-book-sheet-footer')) : find.byType(BottomSheetFooter); + final footerBottom = tester.getBottomRight(footer).dy; await tester.drag(scrollable, const Offset(0, -70)); await tester.pumpAndSettle(); @@ -150,6 +162,7 @@ void main() { ), ), ); + final draggable = tester.widget(find.byType(DraggableScrollableSheet)); expect(draggable.initialChildSize, lessThan(.8)); expect(draggable.maxChildSize, draggable.initialChildSize); @@ -177,6 +190,7 @@ void main() { ), ), ); + final draggable = tester.widget(find.byType(DraggableScrollableSheet)); expect(draggable.maxChildSize, greaterThan(.8)); expect(draggable.maxChildSize, lessThan(1)); @@ -194,6 +208,7 @@ void main() { (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_header_test.dart b/app/test/widgets/shared/bottom_sheet_header_test.dart index d84b5e1c..3f53216b 100644 --- a/app/test/widgets/shared/bottom_sheet_header_test.dart +++ b/app/test/widgets/shared/bottom_sheet_header_test.dart @@ -28,7 +28,6 @@ void main() { testWidgets('renders title, cancel, and save buttons', (tester) async { await tester.pumpWidget(buildHeader(title: 'Edit bookmark')); - expect(find.text('Edit bookmark'), findsOneWidget); expect(find.text('Cancel'), findsOneWidget); expect(find.text('Save'), findsOneWidget); @@ -37,7 +36,6 @@ void main() { testWidgets('cancel button calls onCancel', (tester) async { var cancelled = false; await tester.pumpWidget(buildHeader(onCancel: () => cancelled = true)); - await tester.tap(find.text('Cancel')); expect(cancelled, isTrue); }); @@ -45,7 +43,6 @@ void main() { testWidgets('save button calls onSave', (tester) async { var saved = false; await tester.pumpWidget(buildHeader(onSave: () => saved = true)); - await tester.tap(find.text('Save')); expect(saved, isTrue); }); @@ -53,7 +50,6 @@ void main() { testWidgets('save button is disabled when canSave is false', (tester) async { var saved = false; await tester.pumpWidget(buildHeader(canSave: false, onSave: () => saved = true)); - await tester.tap(find.text('Save')); expect(saved, isFalse); }); @@ -61,7 +57,6 @@ void main() { testWidgets('cancel button is disabled when canCancel is false', (tester) async { var cancelled = false; await tester.pumpWidget(buildHeader(canCancel: false, onCancel: () => cancelled = true)); - final cancelButton = tester.widget(find.widgetWithText(TextButton, 'Cancel')); expect(cancelButton.onPressed, isNull); await tester.tap(find.text('Cancel')); @@ -70,14 +65,12 @@ void main() { testWidgets('uses custom save label', (tester) async { await tester.pumpWidget(buildHeader(saveLabel: 'Done')); - expect(find.text('Done'), findsOneWidget); expect(find.text('Save'), findsNothing); }); testWidgets('save button is a FilledButton', (tester) async { await tester.pumpWidget(buildHeader()); - final filledButton = find.ancestor(of: find.text('Save'), matching: find.byType(FilledButton)); expect(filledButton, findsOneWidget); }); @@ -88,9 +81,7 @@ void main() { tester.platformDispatcher.textScaleFactorTestValue = 2; addTearDown(tester.view.reset); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - await tester.pumpWidget(buildHeader(title: 'Search all monitored books', saveLabel: 'Run')); - expect(tester.takeException(), isNull); expect(find.text('Search all monitored books'), findsOneWidget); expect(find.widgetWithText(TextButton, 'Cancel'), findsOneWidget); diff --git a/app/test/widgets/shared/bottom_sheet_layouts_test.dart b/app/test/widgets/shared/bottom_sheet_layouts_test.dart index 2e0a72e1..115a3f8d 100644 --- a/app/test/widgets/shared/bottom_sheet_layouts_test.dart +++ b/app/test/widgets/shared/bottom_sheet_layouts_test.dart @@ -25,6 +25,7 @@ GoalsProvider goalProvider(BuildContext context) { void main() { final book = buildTestBook(title: 'A long book title for checking compact sheet headers'); + final launchers = { 'shelf assignment': (context) => MoveToShelfSheet.show(context, book: book), 'topic assignment': (context) => ManageTopicsSheet.show(context, book: book), @@ -51,6 +52,7 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); final store = DataStore()..loadData(shelves: const [], tags: const []); addTearDown(store.dispose); + await tester.pumpWidget( ChangeNotifierProvider.value( value: store, @@ -64,6 +66,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); diff --git a/app/test/widgets/shared/empty_state_test.dart b/app/test/widgets/shared/empty_state_test.dart index 6e69cea0..fad8b05b 100644 --- a/app/test/widgets/shared/empty_state_test.dart +++ b/app/test/widgets/shared/empty_state_test.dart @@ -46,6 +46,7 @@ void main() { action: ElevatedButton(onPressed: () {}, child: const Text('Add books')), ), ); + expect(find.text('Add books'), findsOneWidget); expect(find.byType(ElevatedButton), findsOneWidget); }); @@ -96,6 +97,7 @@ void main() { ), ), ); + final fullIcon = tester.widget(find.byIcon(Icons.shelves)); final compactIcon = tester.widget(find.byIcon(Icons.library_books_outlined)); expect(fullIcon.color, compactIcon.color); @@ -104,16 +106,19 @@ void main() { final compactTitle = tester.widget(find.text('Compact title')); expect(title.style?.color, compactTitle.style?.color); expect(title.style?.color, theme.colorScheme.onSurfaceVariant); + expect( tester.widget(find.text('Full description')).style, tester.widget(find.text('Compact description')).style, ); + expect(tester.takeException(), isNull); }); } testWidgets('message and action remain reachable in a short viewport with large text', (tester) async { var called = false; + await tester.pumpWidget( MaterialApp( home: MediaQuery( @@ -133,6 +138,7 @@ void main() { ), ), ); + expect(tester.takeException(), isNull); final scroll = tester.state(find.byType(Scrollable)); expect(scroll.position.maxScrollExtent, greaterThan(0)); diff --git a/app/test/widgets/shared/page_empty_states_test.dart b/app/test/widgets/shared/page_empty_states_test.dart index 321d8a18..f2677317 100644 --- a/app/test/widgets/shared/page_empty_states_test.dart +++ b/app/test/widgets/shared/page_empty_states_test.dart @@ -25,6 +25,7 @@ void main() { tester.view.physicalSize = Size(width, 900); addTearDown(tester.view.reset); var calls = 0; + final tabs = [ (BookNotes(notes: const [], onAddNote: () => calls++), Icons.note_outlined, 'Add note'), ( @@ -43,6 +44,7 @@ void main() { 'Add annotation', ), ]; + for (final (tab, icon, action) in tabs) { await tester.pumpWidget( MaterialApp( @@ -53,9 +55,11 @@ void main() { ), ), ); + await tester.pumpAndSettle(); expect(tester.takeException(), isNull); expect(tester.getTopLeft(find.byIcon(icon)).dy, Spacing.xl); + if (isPhysical || action == 'Add note') { expect(find.text(action).hitTestable(), findsOneWidget); await tester.tap(find.text(action)); @@ -63,6 +67,7 @@ void main() { expect(find.text(action), findsNothing); } } + expect(calls, isPhysical ? 3 : 1); }, ); @@ -93,17 +98,21 @@ void main() { ), ), ); + expect(tester.takeException(), isNull); expect(find.byType(EmptyState), findsOneWidget); final title = tester.widget(find.text(entry.key)); expect(title.style?.color, theme.colorScheme.onSurfaceVariant); expect(title.textAlign, TextAlign.center); + // The original card message/action can still be reached when it is tall. for (final scroll in tester.stateList(find.byType(Scrollable))) { scroll.position.jumpTo(scroll.position.maxScrollExtent); } + await tester.pump(); final action = find.byType(EmptyStateAction); + if (action.evaluate().isNotEmpty) { expect(find.descendant(of: action, matching: find.byType(FilledButton)).hitTestable(), findsOneWidget); } @@ -130,9 +139,11 @@ void main() { ), ), ); + expect(tester.takeException(), isNull); expect(find.text('Browse library').hitTestable(), findsOneWidget); expect(find.text('Set a goal').hitTestable(), findsOneWidget); + expect( tester.getSize(find.byType(ContinueReadingCard)).height, tester.getSize(find.byType(ReadingGoalCard)).height, diff --git a/app/test/widgets/shared/selection_sheets_empty_state_test.dart b/app/test/widgets/shared/selection_sheets_empty_state_test.dart index 56cfa3ee..29d02e98 100644 --- a/app/test/widgets/shared/selection_sheets_empty_state_test.dart +++ b/app/test/widgets/shared/selection_sheets_empty_state_test.dart @@ -26,12 +26,12 @@ void main() { screenSize: const Size(800, 1200), ), ); + await tester.pumpAndSettle(); } testWidgets('topics empty state is centered across the sheet', (tester) async { await pumpSheet(tester, const ManageTopicsSheet(bulkBookIds: ['book-1'])); - final title = find.text('No topics yet'); expect(title, findsOneWidget); expect(tester.getCenter(title).dx, closeTo(400, 20)); @@ -39,7 +39,6 @@ void main() { testWidgets('shelves empty state is present and centered across the sheet', (tester) async { await pumpSheet(tester, const MoveToShelfSheet(bulkBookIds: ['book-1'])); - final title = find.text('No shelves yet'); expect(title, findsOneWidget); expect(tester.getCenter(title).dx, closeTo(400, 20)); @@ -53,13 +52,16 @@ void main() { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; addTearDown(tester.view.reset); + if (size.width == 320) { tester.platformDispatcher.textScaleFactorTestValue = 1.5; addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); } + final store = DataStore()..loadData(shelves: const [], tags: const []); addTearDown(store.dispose); final book = buildTestBook(title: 'A long book title that must remain in the header'); + await tester.pumpWidget( ChangeNotifierProvider.value( value: store, @@ -78,6 +80,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -87,10 +90,12 @@ void main() { final footer = tester.getRect(find.byType(BottomSheetFooter)); expect(empty.top, greaterThanOrEqualTo(header.bottom)); expect(empty.bottom, lessThanOrEqualTo(footer.top), reason: 'empty=$empty header=$header footer=$footer'); + expect( find.text(topics ? 'Tap + to create a topic' : 'Tap + to create a shelf').hitTestable(), findsOneWidget, ); + for (final scroll in tester.stateList(find.byType(Scrollable))) { expect(scroll.position.maxScrollExtent, 0); } @@ -103,7 +108,9 @@ void main() { (tester) async { final store = DataStore() ..loadData(shelves: topics ? const [] : [buildTestShelf()], tags: topics ? [buildTestTag()] : const []); + addTearDown(store.dispose); + await tester.pumpWidget( createTestPage( page: Scaffold( @@ -114,6 +121,7 @@ void main() { dataStore: store, ), ); + await tester.pumpAndSettle(); expect(find.byType(SearchField), findsOneWidget); await tester.enterText(find.byType(TextField), 'no matches'); diff --git a/app/test/widgets/shell/eink_layout_test.dart b/app/test/widgets/shell/eink_layout_test.dart index 8b6f339c..c1abe81f 100644 --- a/app/test/widgets/shell/eink_layout_test.dart +++ b/app/test/widgets/shell/eink_layout_test.dart @@ -23,6 +23,7 @@ void main() { SharedPreferences.setMockInitialValues({'theme_mode': 'light'}); final prefs = PreferencesProvider(await SharedPreferences.getInstance()); final catalogStore = OpdsCatalogStore(await SharedPreferences.getInstance()); + final router = GoRouter( initialLocation: '/library/books', routes: [ @@ -32,7 +33,9 @@ void main() { ), ], ); + addTearDown(router.dispose); + await tester.pumpWidget( MultiProvider( providers: [ @@ -49,6 +52,7 @@ void main() { ), ), ); + await tester.pumpAndSettle(); final desktop = find.byType(DesktopSidebar).evaluate().isNotEmpty; final mobile = find.byType(MobileBottomNav).evaluate().isNotEmpty; diff --git a/app/test/widgets/shell/library_drawer_consistency_test.dart b/app/test/widgets/shell/library_drawer_consistency_test.dart index 08aeeed7..7add1fc7 100644 --- a/app/test/widgets/shell/library_drawer_consistency_test.dart +++ b/app/test/widgets/shell/library_drawer_consistency_test.dart @@ -32,6 +32,7 @@ void main() { (path: '/library/annotations', label: 'Annotations', page: AnnotationsPage()), (path: '/library/notes', label: 'Notes', page: NotesPage()), ]; + for (final theme in [AppTheme.dark, AppTheme.eink]) { for (final layout in [(width: 400.0, height: 850.0, scale: 1.0), (width: 320.0, height: 568.0, scale: 2.0)]) { testWidgets( @@ -41,15 +42,19 @@ void main() { tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs( OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()), )..setScope('guest'); + await catalogs.save( OpdsCatalog(id: 'one', name: 'Sample catalog', uri: Uri.parse('https://catalog.test/feed')), ); + final store = createTestDataStore(books: createTestBooks().take(1).toList(), shelves: []); final library = LibraryProvider(); final downloads = OpdsDownloads(captureImport: () => throw StateError('Unexpected import')); + final router = GoRouter( initialLocation: '/library/catalogs', routes: [ @@ -59,6 +64,7 @@ void main() { ), ], ); + addTearDown(() async { await tester.pumpWidget(const SizedBox.shrink()); router.dispose(); @@ -67,6 +73,7 @@ void main() { library.dispose(); downloads.dispose(); }); + await tester.pumpWidget( MultiProvider( providers: [ @@ -86,18 +93,22 @@ void main() { ), ), ); + await tester.pumpAndSettle(); // Every page opens the shell drawer, covering the bottom navigation. // Follow real drawer links through every page and back to Catalogs. final journey = [sections[2], ...sections.where((section) => section.label != 'Catalogs'), sections[2]]; + for (var index = 0; index < journey.length; index++) { final section = journey[index]; expect(router.routeInformationProvider.value.uri.path, section.path); + if (theme.brightness == Brightness.dark && section.label == 'Books') { await tester.dragFrom(Offset(1, layout.height / 2), const Offset(280, 0)); } else { await tester.tap(find.byTooltip('Library sections')); } + await tester.pumpAndSettle(); final drawer = find.byType(Drawer); expect(drawer, findsOneWidget); @@ -106,29 +117,35 @@ void main() { final scrollable = find.descendant(of: drawer, matching: find.byType(Scrollable)); await tester.drag(scrollable, const Offset(0, 2000)); await tester.pumpAndSettle(); + expect( tester .widgetList(find.descendant(of: drawer, matching: find.byType(NavItemCount))) .map((badge) => badge.count), [1, 1], ); + expect( tester.widget(find.descendant(of: drawer, matching: find.text('Library'))).style?.fontWeight, FontWeight.bold, ); + expect(tester.takeException(), isNull); final selected = find.descendant(of: drawer, matching: find.widgetWithText(ListTile, section.label)); await tester.scrollUntilVisible(selected, 120, scrollable: scrollable); await tester.ensureVisible(selected); await tester.pumpAndSettle(); expect(tester.widget(selected).selected, isTrue); + if (index + 1 < journey.length) { await tester.drag(scrollable, const Offset(0, 2000)); await tester.pumpAndSettle(); + final target = find.descendant( of: drawer, matching: find.widgetWithText(ListTile, journey[index + 1].label), ); + await tester.scrollUntilVisible(target, 120, scrollable: scrollable); await tester.ensureVisible(target); await tester.pumpAndSettle(); diff --git a/app/test/widgets/shell/navigation_counts_test.dart b/app/test/widgets/shell/navigation_counts_test.dart index 413dbe4e..555b9f3d 100644 --- a/app/test/widgets/shell/navigation_counts_test.dart +++ b/app/test/widgets/shell/navigation_counts_test.dart @@ -21,9 +21,12 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); SharedPreferences.setMockInitialValues({}); + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) ..setScope('guest'); + addTearDown(catalogs.dispose); + final router = GoRouter( initialLocation: '/library/catalogs', routes: [ @@ -33,7 +36,9 @@ void main() { ), ], ); + addTearDown(router.dispose); + await tester.pumpWidget( MultiProvider( providers: [ @@ -44,35 +49,33 @@ void main() { child: MaterialApp.router(routerConfig: router), ), ); + await tester.pumpAndSettle(); + if (width < 840) { tester.state(find.byType(Scaffold).first).openDrawer(); await tester.pumpAndSettle(); } + expect(find.text('Catalogs'), findsOneWidget); expect(find.byType(NavItemCount), findsNothing); expect(find.text('0'), findsNothing); - await catalogs.save(OpdsCatalog(id: 'one', name: 'One', uri: Uri.parse('https://one.test/feed'))); await tester.pumpAndSettle(); expect(tester.widget(find.byType(NavItemCount)).count, 1); expect(tester.widget(find.byType(NavItemCount)).selected, isTrue); - await catalogs.save(OpdsCatalog(id: 'two', name: 'Two', uri: Uri.parse('https://two.test/feed'))); await tester.pumpAndSettle(); expect(tester.widget(find.byType(NavItemCount)).count, 2); - await catalogs.remove('one'); await tester.pumpAndSettle(); expect(tester.widget(find.byType(NavItemCount)).count, 1); - catalogs.setScope('another-account'); await tester.pumpAndSettle(); expect(find.byType(NavItemCount), findsNothing); catalogs.setScope('guest'); await tester.pumpAndSettle(); expect(tester.widget(find.byType(NavItemCount)).count, 1); - await catalogs.remove('two'); await tester.pumpAndSettle(); expect(find.byType(NavItemCount), findsNothing); diff --git a/app/test/widgets/static_date_picker_test.dart b/app/test/widgets/static_date_picker_test.dart index facc5f47..750fd98e 100644 --- a/app/test/widgets/static_date_picker_test.dart +++ b/app/test/widgets/static_date_picker_test.dart @@ -7,6 +7,7 @@ import 'package:papyrus/widgets/shared/app_drawer.dart'; void main() { testWidgets('e-ink calendar changes months and years instantly and saves a date', (tester) async { DateTime? result; + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -27,6 +28,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pump(); await tester.pump(); @@ -50,6 +52,7 @@ void main() { testWidgets('e-ink range selection spans months and cancel preserves the original', (tester) async { DateTimeRange? result; + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -70,6 +73,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pump(); await tester.pump(); @@ -94,6 +98,7 @@ void main() { testWidgets('e-ink drawer opens and dismisses instantly at its regular width', (tester) async { final scaffold = GlobalKey(); + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -107,6 +112,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pump(); await tester.pump(); @@ -121,6 +127,7 @@ void main() { testWidgets('e-ink typed dates validate and survive switching back to calendar', (tester) async { DateTime? result; + await tester.pumpWidget( MaterialApp( theme: AppTheme.eink, @@ -141,6 +148,7 @@ void main() { ), ), ); + await tester.tap(find.text('Open')); await tester.pump(); await tester.pump();