mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: standardize error handling

+562 -122
+38
doc/errors.md
··· 1 + # Error Handling Patterns 2 + 3 + This project standardizes error handling so UI messages stay user-friendly and 4 + network failures are predictable. 5 + 6 + ## Standard Error Types 7 + 8 + - `NetworkFailure` (in `lib/src/infrastructure/network/network_failure.dart`) 9 + - `ConnectionFailure`, `AuthFailure`, `RateLimitFailure`, `ClientFailure`, 10 + `ServerFailure`, `DecodeFailure` 11 + - `OAuthException` (in `lib/src/infrastructure/auth/oauth_exceptions.dart`) 12 + - `ValidationError` (in `lib/src/features/dms/domain/outbox_error.dart`) 13 + 14 + ## UI Messaging 15 + 16 + Use `errorMessage(error)` from `lib/src/core/utils/error_message.dart` instead of 17 + `error.toString()` to avoid leaking raw exception text into the UI. 18 + 19 + Example: 20 + 21 + ```dart 22 + ErrorView( 23 + title: 'Failed to load feed', 24 + message: errorMessage(error), 25 + onRetry: () => ref.read(feedContentProvider(uri).notifier).refresh(), 26 + ); 27 + ``` 28 + 29 + ## Logging vs Display 30 + 31 + - Log raw errors and stacks for debugging (`logger.error(...)`). 32 + - Display standardized UI messages via `errorMessage(...)`. 33 + - Prefer typed errors (e.g., `NetworkFailure`) at repository boundaries. 34 + 35 + ## Tests 36 + 37 + Add or update tests whenever new error types are introduced or UI messaging 38 + changes, especially for networking/auth flows.
+1 -1
doc/roadmap.txt
··· 16 16 - [ ] Type safety improvements: 17 17 - Use freezed for critical models 18 18 - Add typed JSON parsing where appropriate 19 - - [ ] Error handling standardization: 19 + - [x] Error handling standardization: 20 20 - Define standard error types for common failures (network, auth, 21 21 validation) 22 22 - Standardize error display messages (no raw .toString() in UI)
+49
lib/src/core/utils/error_message.dart
··· 1 + import 'package:lazurite/src/features/dms/domain/outbox_error.dart'; 2 + import 'package:lazurite/src/infrastructure/auth/oauth_exceptions.dart'; 3 + import 'package:lazurite/src/infrastructure/network/network_failure.dart'; 4 + 5 + /// Returns a user-friendly error message for UI display. 6 + String errorMessage(Object? error, {String fallback = 'Something went wrong. Please try again.'}) { 7 + if (error == null) return fallback; 8 + 9 + if (error is NetworkFailure) { 10 + return switch (error) { 11 + ConnectionFailure() => 'Network error. Check your connection and try again.', 12 + AuthFailure(:final requiresReauth) => 13 + requiresReauth 14 + ? 'Session expired. Please sign in again.' 15 + : 'Authentication failed. Please try again.', 16 + RateLimitFailure(:final retryAfter) => 17 + retryAfter != null 18 + ? 'Too many requests. Try again in ${retryAfter.inSeconds}s.' 19 + : 'Too many requests. Try again later.', 20 + ServerFailure() => 'Server error. Please try again later.', 21 + ClientFailure(:final statusCode) => 22 + statusCode == 404 23 + ? 'Not found. The item may have been deleted.' 24 + : 'Request failed. Please try again.', 25 + DecodeFailure() => 'We received an unexpected response. Please try again.', 26 + }; 27 + } 28 + 29 + if (error is OAuthException) { 30 + return error.errorDescription ?? 'Authorization failed. Please try again.'; 31 + } 32 + 33 + if (error is ValidationError) { 34 + return error.message ?? 'Invalid input. Please update and retry.'; 35 + } 36 + 37 + if (error is FormatException) { 38 + return error.message.isNotEmpty ? error.message : fallback; 39 + } 40 + 41 + if (error is StateError) { 42 + final message = error.message; 43 + if (message.contains('authenticated')) { 44 + return 'Please sign in to continue.'; 45 + } 46 + } 47 + 48 + return fallback; 49 + }
+4 -1
lib/src/features/composer/application/composer_notifier.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:equatable/equatable.dart'; 4 + import 'package:lazurite/src/core/utils/error_message.dart'; 4 5 import 'package:lazurite/src/features/composer/domain/draft.dart'; 5 6 import 'package:lazurite/src/features/composer/infrastructure/draft_repository.dart'; 6 7 import 'package:lazurite/src/features/profile/application/profile_providers.dart'; ··· 219 220 } catch (e) { 220 221 final updatedState = state.asData?.value; 221 222 if (updatedState != null) { 222 - state = AsyncValue.data(updatedState.copyWith(isPublishing: false, error: e.toString())); 223 + state = AsyncValue.data( 224 + updatedState.copyWith(isPublishing: false, error: errorMessage(e)), 225 + ); 223 226 } 224 227 return null; 225 228 }
+2 -1
lib/src/features/composer/infrastructure/draft_repository.dart
··· 3 3 4 4 import 'package:dio/dio.dart'; 5 5 import 'package:drift/drift.dart' show Value; 6 + import 'package:lazurite/src/core/utils/error_message.dart'; 6 7 import 'package:lazurite/src/core/utils/image_compressor.dart'; 7 8 import 'package:lazurite/src/core/utils/logger.dart'; 8 9 import 'package:lazurite/src/features/composer/domain/draft.dart' as composer; ··· 288 289 ownerDid, 289 290 DraftsCompanion( 290 291 status: Value(composer.DraftStatus.failed.name), 291 - errorMessage: Value(e.toString()), 292 + errorMessage: Value(errorMessage(e)), 292 293 updatedAt: Value(DateTime.now()), 293 294 ), 294 295 );
+2 -1
lib/src/features/composer/presentation/screens/composer_screen.dart
··· 3 3 import 'package:go_router/go_router.dart'; 4 4 import 'package:image_picker/image_picker.dart'; 5 5 import 'package:lazurite/src/core/domain/post.dart'; 6 + import 'package:lazurite/src/core/utils/error_message.dart'; 6 7 import 'package:lazurite/src/features/composer/application/composer_notifier.dart'; 7 8 import 'package:lazurite/src/features/composer/application/composer_providers.dart'; 8 9 import 'package:lazurite/src/features/composer/presentation/widgets/alt_text_editor_sheet.dart'; ··· 501 502 Text('Failed to load composer', style: theme.textTheme.bodyLarge), 502 503 const SizedBox(height: 8), 503 504 Text( 504 - error.toString(), 505 + errorMessage(error), 505 506 style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), 506 507 textAlign: TextAlign.center, 507 508 ),
+2 -1
lib/src/features/developer_tools/presentation/screens/collections_page.dart
··· 3 3 import 'package:go_router/go_router.dart'; 4 4 import 'package:lazurite/src/app/providers.dart'; 5 5 import 'package:lazurite/src/app/routes.dart'; 6 + import 'package:lazurite/src/core/utils/error_message.dart'; 6 7 import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart'; 7 8 import 'package:lazurite/src/features/developer_tools/domain/repo_collection.dart'; 8 9 ··· 119 120 Text('Failed to load collections', style: theme.textTheme.titleMedium), 120 121 const SizedBox(height: 8), 121 122 Text( 122 - error.toString(), 123 + errorMessage(error), 123 124 textAlign: TextAlign.center, 124 125 style: theme.textTheme.bodySmall?.copyWith( 125 126 color: theme.colorScheme.onSurfaceVariant,
+2 -1
lib/src/features/developer_tools/presentation/screens/record_detail_page.dart
··· 5 5 import 'package:flutter_json/flutter_json.dart'; 6 6 import 'package:flutter_riverpod/flutter_riverpod.dart'; 7 7 import 'package:go_router/go_router.dart'; 8 + import 'package:lazurite/src/core/utils/error_message.dart'; 8 9 import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart'; 9 10 import 'package:lazurite/src/features/developer_tools/domain/repo_record.dart'; 10 11 ··· 67 68 Text('Failed to load record', style: theme.textTheme.titleMedium), 68 69 const SizedBox(height: 8), 69 70 Text( 70 - error.toString(), 71 + errorMessage(error), 71 72 textAlign: TextAlign.center, 72 73 style: theme.textTheme.bodySmall?.copyWith( 73 74 color: theme.colorScheme.onSurfaceVariant,
+3 -2
lib/src/features/developer_tools/presentation/screens/records_page.dart
··· 3 3 import 'package:go_router/go_router.dart'; 4 4 import 'package:lazurite/src/app/routes.dart'; 5 5 import 'package:lazurite/src/app/providers.dart'; 6 + import 'package:lazurite/src/core/utils/error_message.dart'; 6 7 import 'package:lazurite/src/features/developer_tools/application/devtools_providers.dart'; 7 8 import 'package:lazurite/src/features/developer_tools/domain/repo_record.dart'; 8 9 ··· 62 63 Text('Failed to load records', style: theme.textTheme.titleMedium), 63 64 const SizedBox(height: 8), 64 65 Text( 65 - state.error.toString(), 66 + errorMessage(state.error), 66 67 textAlign: TextAlign.center, 67 68 style: theme.textTheme.bodySmall?.copyWith( 68 69 color: theme.colorScheme.onSurfaceVariant, ··· 127 128 Text('Failed to load records', style: theme.textTheme.titleMedium), 128 129 const SizedBox(height: 8), 129 130 Text( 130 - error.toString(), 131 + errorMessage(error), 131 132 textAlign: TextAlign.center, 132 133 style: theme.textTheme.bodySmall?.copyWith( 133 134 color: theme.colorScheme.onSurfaceVariant,
+2 -1
lib/src/features/dms/presentation/conversation_detail_screen.dart
··· 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 4 4 import '../../../core/animations/animation_utils.dart'; 5 + import '../../../core/utils/error_message.dart'; 5 6 import '../../../core/widgets/error_view.dart'; 6 7 import '../../../core/widgets/loading_view.dart'; 7 8 import '../../../core/widgets/pull_to_refresh_wrapper.dart'; ··· 162 163 loading: () => const LoadingView(), 163 164 error: (error, stack) => ErrorView( 164 165 title: 'Failed to load messages', 165 - message: error.toString(), 166 + message: errorMessage(error), 166 167 onRetry: () => 167 168 ref.read(conversationDetailProvider(widget.convoId).notifier).refresh(), 168 169 ),
+2 -1
lib/src/features/dms/presentation/conversation_list_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/src/core/utils/error_message.dart'; 4 5 import 'package:lazurite/src/features/dms/presentation/widgets/message_request_card.dart'; 5 6 6 7 import '../../../core/animations/animation_utils.dart'; ··· 235 236 loading: () => const LoadingView(), 236 237 error: (error, stack) => ErrorView( 237 238 title: 'Failed to load messages', 238 - message: error.toString(), 239 + message: errorMessage(error), 239 240 onRetry: () => ref.read(conversationListProvider.notifier).refresh(), 240 241 ), 241 242 ),
+2 -1
lib/src/features/feeds/application/feed_providers.dart
··· 1 1 import 'dart:async'; 2 2 3 3 import 'package:lazurite/src/app/providers.dart'; 4 + import 'package:lazurite/src/core/utils/error_message.dart'; 4 5 import 'package:lazurite/src/core/utils/logger_provider.dart'; 5 6 import 'package:lazurite/src/features/auth/application/auth_providers.dart'; 6 7 import 'package:lazurite/src/features/auth/domain/auth_state.dart'; ··· 432 433 ); 433 434 state = state.copyWith(results: results, isLoading: false); 434 435 } catch (e) { 435 - state = state.copyWith(isLoading: false, error: e.toString()); 436 + state = state.copyWith(isLoading: false, error: errorMessage(e)); 436 437 } 437 438 } 438 439
+203 -98
lib/src/features/feeds/infrastructure/feed_repository.dart
··· 8 8 import 'package:lazurite/src/infrastructure/db/daos/profile_dao.dart'; 9 9 import 'package:lazurite/src/infrastructure/db/daos/saved_feeds_dao.dart'; 10 10 import 'package:lazurite/src/infrastructure/network/xrpc_client.dart'; 11 + import 'package:uuid/uuid.dart'; 11 12 12 13 /// Duration after which permanently failed sync items are cleaned up. 13 14 const Duration kSyncQueueCleanupAge = Duration(days: 30); ··· 19 20 /// from app.bsky.feed.getFeedGenerator. 20 21 class FeedRepository { 21 22 FeedRepository(this._api, this._dao, this._syncQueueDao, this._profileDao, this._logger); 23 + 24 + static const _uuid = Uuid(); 22 25 23 26 final XrpcClient _api; 24 27 final SavedFeedsDao _dao; ··· 390 393 if (!_api.isAuthenticated) throw Exception('User not authenticated'); 391 394 _validateFeedUri(feedUri); 392 395 396 + final existing = await _dao.getFeed(feedUri, ownerDid); 393 397 FeedGenerator? metadata; 394 398 String displayName = 'Saved Feed'; 395 399 String? description; ··· 420 424 } 421 425 422 426 final allFeeds = await _dao.getAllFeeds(ownerDid); 427 + final sortOrder = existing?.sortOrder ?? allFeeds.length; 428 + final lastSynced = existing?.lastSynced ?? DateTime.now(); 423 429 int? queueId; 424 430 425 431 try { ··· 433 439 avatar: Value(avatar), 434 440 creatorDid: creatorDid, 435 441 likeCount: Value(likeCount ?? 0), 436 - sortOrder: allFeeds.length, 442 + sortOrder: sortOrder, 437 443 isPinned: Value(pin), 438 - lastSynced: DateTime.now(), 444 + lastSynced: lastSynced, 439 445 localUpdatedAt: Value(DateTime.now()), 440 446 ), 441 447 ); ··· 453 459 try { 454 460 await _executeRemoteSaveFeed(feedUri, pin); 455 461 if (queueId != null) await _syncQueueDao.deleteItem(queueId); 462 + await _clearLocalModifications([feedUri], ownerDid); 456 463 } catch (e) { 457 464 _logger.error('Failed to save feed', {'error': e}); 458 465 } 459 466 } 460 467 461 468 Future<void> _executeRemoteSaveFeed(String feedUri, bool pin) async { 462 - final currentPrefsResponse = await _api.call('app.bsky.actor.getPreferences'); 463 - final prefs = List<Map<String, dynamic>>.from( 464 - (currentPrefsResponse['preferences'] as List).map( 465 - (p) => Map<String, dynamic>.from(p as Map), 466 - ), 467 - ); 469 + final snapshot = await _loadSavedFeedsPreferences(); 468 470 469 - var savedFeedsPref = prefs.cast<Map<String, dynamic>>().firstWhere( 470 - (p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref', 471 - orElse: () => <String, dynamic>{}, 472 - ); 473 - 474 - if (savedFeedsPref.isEmpty) { 475 - savedFeedsPref = { 476 - '\$type': 'app.bsky.actor.defs#savedFeedsPref', 477 - 'saved': <String>[], 478 - 'pinned': <String>[], 479 - }; 480 - prefs.add(savedFeedsPref); 471 + if (snapshot.v2 != null) { 472 + final updatedItems = _upsertV2Item(snapshot.v2!.items, feedUri, pin); 473 + _updatePreferences( 474 + snapshot, 475 + v2: SavedFeedsPrefV2(items: updatedItems), 476 + v1: snapshot.v1, 477 + ); 481 478 } else { 482 - savedFeedsPref = Map<String, dynamic>.from(savedFeedsPref); 483 - final index = prefs.indexWhere((p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref'); 484 - prefs[index] = savedFeedsPref; 485 - } 479 + final existingSaved = snapshot.v1?.saved ?? []; 480 + final existingPinned = snapshot.v1?.pinned ?? []; 481 + final saved = List<String>.from(existingSaved); 482 + final pinned = List<String>.from(existingPinned); 486 483 487 - final saved = List<String>.from(savedFeedsPref['saved'] ?? []); 488 - final pinned = List<String>.from(savedFeedsPref['pinned'] ?? []); 484 + if (!saved.contains(feedUri)) { 485 + saved.add(feedUri); 486 + } 489 487 490 - if (!saved.contains(feedUri)) { 491 - saved.add(feedUri); 492 - } 488 + if (pin) { 489 + if (!pinned.contains(feedUri)) { 490 + pinned.add(feedUri); 491 + } 492 + } else { 493 + pinned.remove(feedUri); 494 + } 493 495 494 - if (pin && !pinned.contains(feedUri)) { 495 - pinned.add(feedUri); 496 + _updatePreferences( 497 + snapshot, 498 + v1: SavedFeedsPref(saved: saved, pinned: pinned), 499 + ); 496 500 } 497 501 498 - savedFeedsPref['saved'] = saved; 499 - savedFeedsPref['pinned'] = pinned; 500 - 501 - await _api.call('app.bsky.actor.putPreferences', body: {'preferences': prefs}); 502 + await _api.call('app.bsky.actor.putPreferences', body: {'preferences': snapshot.preferences}); 502 503 } 503 504 504 505 /// Removes a feed from user preferences and local cache. ··· 528 529 } 529 530 530 531 Future<void> _executeRemoteRemoveFeed(String feedUri) async { 531 - final currentPrefsResponse = await _api.call('app.bsky.actor.getPreferences'); 532 - final prefs = List<Map<String, dynamic>>.from( 533 - (currentPrefsResponse['preferences'] as List).map( 534 - (p) => Map<String, dynamic>.from(p as Map), 535 - ), 536 - ); 532 + final snapshot = await _loadSavedFeedsPreferences(); 537 533 538 - var savedFeedsPref = prefs.cast<Map<String, dynamic>>().firstWhere( 539 - (p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref', 540 - orElse: () => <String, dynamic>{}, 541 - ); 542 - 543 - if (savedFeedsPref.isNotEmpty) { 544 - savedFeedsPref = Map<String, dynamic>.from(savedFeedsPref); 545 - final index = prefs.indexWhere((p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref'); 546 - prefs[index] = savedFeedsPref; 547 - 548 - final saved = List<String>.from(savedFeedsPref['saved'] ?? []); 549 - final pinned = List<String>.from(savedFeedsPref['pinned'] ?? []); 550 - 551 - saved.remove(feedUri); 552 - pinned.remove(feedUri); 553 - 554 - savedFeedsPref['saved'] = saved; 555 - savedFeedsPref['pinned'] = pinned; 556 - 557 - await _api.call('app.bsky.actor.putPreferences', body: {'preferences': prefs}); 534 + if (snapshot.v2 != null) { 535 + final updatedItems = snapshot.v2!.items.where((item) => item.value != feedUri).toList(); 536 + _updatePreferences( 537 + snapshot, 538 + v2: SavedFeedsPrefV2(items: updatedItems), 539 + v1: snapshot.v1, 540 + ); 541 + } else if (snapshot.v1 != null) { 542 + final saved = List<String>.from(snapshot.v1!.saved)..remove(feedUri); 543 + final pinned = List<String>.from(snapshot.v1!.pinned)..remove(feedUri); 544 + _updatePreferences( 545 + snapshot, 546 + v1: SavedFeedsPref(saved: saved, pinned: pinned), 547 + ); 548 + } else { 549 + return; 558 550 } 551 + 552 + await _api.call('app.bsky.actor.putPreferences', body: {'preferences': snapshot.preferences}); 559 553 } 560 554 561 555 /// Reorders feeds according to the provided URI list. ··· 585 579 try { 586 580 await _executeRemoteReorderFeeds(orderedUris); 587 581 if (queueId != null) await _syncQueueDao.deleteItem(queueId); 582 + await _clearLocalModifications(orderedUris, ownerDid); 588 583 } catch (e) { 589 584 _logger.error('Failed to reorder feeds', {'error': e}); 590 585 } 591 586 } 592 587 593 588 Future<void> _executeRemoteReorderFeeds(List<String> orderedUris) async { 594 - final currentPrefsResponse = await _api.call('app.bsky.actor.getPreferences'); 595 - final prefs = List<Map<String, dynamic>>.from( 596 - (currentPrefsResponse['preferences'] as List).map( 597 - (p) => Map<String, dynamic>.from(p as Map), 598 - ), 599 - ); 589 + final snapshot = await _loadSavedFeedsPreferences(); 600 590 601 - var savedFeedsPref = prefs.cast<Map<String, dynamic>>().firstWhere( 602 - (p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref', 603 - orElse: () => <String, dynamic>{}, 604 - ); 591 + if (snapshot.v2 != null) { 592 + final updatedItems = _reorderV2Items(snapshot.v2!.items, orderedUris); 593 + _updatePreferences( 594 + snapshot, 595 + v2: SavedFeedsPrefV2(items: updatedItems), 596 + v1: snapshot.v1, 597 + ); 598 + } else if (snapshot.v1 != null) { 599 + final currentSaved = List<String>.from(snapshot.v1!.saved); 600 + final currentPinned = List<String>.from(snapshot.v1!.pinned); 601 + final reorderedSaved = <String>[]; 602 + final reorderedPinned = <String>[]; 605 603 606 - if (savedFeedsPref.isEmpty) { 607 - return; 608 - } 604 + for (final uri in orderedUris) { 605 + if (currentSaved.contains(uri)) { 606 + reorderedSaved.add(uri); 607 + if (currentPinned.contains(uri)) { 608 + reorderedPinned.add(uri); 609 + } 610 + } 611 + } 609 612 610 - savedFeedsPref = Map<String, dynamic>.from(savedFeedsPref); 611 - final index = prefs.indexWhere((p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref'); 612 - prefs[index] = savedFeedsPref; 613 - 614 - final currentSaved = List<String>.from(savedFeedsPref['saved'] ?? []); 615 - final currentPinned = List<String>.from(savedFeedsPref['pinned'] ?? []); 616 - 617 - final reorderedSaved = <String>[]; 618 - final reorderedPinned = <String>[]; 619 - 620 - for (final uri in orderedUris) { 621 - if (currentSaved.contains(uri)) { 622 - reorderedSaved.add(uri); 623 - if (currentPinned.contains(uri)) { 624 - reorderedPinned.add(uri); 613 + for (final uri in currentSaved) { 614 + if (!reorderedSaved.contains(uri)) { 615 + reorderedSaved.add(uri); 625 616 } 626 617 } 627 - } 628 618 629 - for (final uri in currentSaved) { 630 - if (!reorderedSaved.contains(uri)) { 631 - reorderedSaved.add(uri); 632 - } 619 + _updatePreferences( 620 + snapshot, 621 + v1: SavedFeedsPref(saved: reorderedSaved, pinned: reorderedPinned), 622 + ); 623 + } else { 624 + return; 633 625 } 634 626 635 - savedFeedsPref['saved'] = reorderedSaved; 636 - savedFeedsPref['pinned'] = reorderedPinned; 637 - 638 - await _api.call('app.bsky.actor.putPreferences', body: {'preferences': prefs}); 627 + await _api.call('app.bsky.actor.putPreferences', body: {'preferences': snapshot.preferences}); 639 628 } 640 629 641 630 /// Discovers trending feed generators or searches for them if a query is provided. ··· 850 839 } 851 840 852 841 await _syncQueueDao.deleteItem(item.id); 842 + if (item.type == 'save') { 843 + await _clearLocalModifications([item.payload], ownerDid); 844 + } else if (item.type == 'reorder') { 845 + await _clearLocalModifications(item.payload.split(','), ownerDid); 846 + } 853 847 } catch (e) { 854 848 _logger.error('Failed to process sync item ${item.id}', { 855 849 'error': e, ··· 1072 1066 }); 1073 1067 return results; 1074 1068 } 1069 + 1070 + Future<_SavedFeedsPreferencesSnapshot> _loadSavedFeedsPreferences() async { 1071 + final currentPrefsResponse = await _api.call('app.bsky.actor.getPreferences'); 1072 + final prefsRaw = currentPrefsResponse['preferences']; 1073 + if (prefsRaw is! List) { 1074 + throw FormatException('preferences must be a List', currentPrefsResponse); 1075 + } 1076 + 1077 + final preferences = prefsRaw 1078 + .map((p) => Map<String, dynamic>.from(p as Map)) 1079 + .toList(growable: true); 1080 + 1081 + final parsed = SavedFeedsPreferenceParser.parse(preferences); 1082 + final v2Index = preferences.indexWhere( 1083 + (p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPrefV2', 1084 + ); 1085 + final v1Index = preferences.indexWhere( 1086 + (p) => p['\$type'] == 'app.bsky.actor.defs#savedFeedsPref', 1087 + ); 1088 + 1089 + return _SavedFeedsPreferencesSnapshot( 1090 + preferences: preferences, 1091 + v2: parsed.v2, 1092 + v1: parsed.v1, 1093 + v2Index: v2Index >= 0 ? v2Index : null, 1094 + v1Index: v1Index >= 0 ? v1Index : null, 1095 + ); 1096 + } 1097 + 1098 + void _updatePreferences( 1099 + _SavedFeedsPreferencesSnapshot snapshot, { 1100 + SavedFeedsPrefV2? v2, 1101 + SavedFeedsPref? v1, 1102 + }) { 1103 + if (v2 != null) { 1104 + final json = v2.toJson(); 1105 + if (snapshot.v2Index != null) { 1106 + snapshot.preferences[snapshot.v2Index!] = json; 1107 + } else { 1108 + snapshot.preferences.add(json); 1109 + } 1110 + 1111 + if (snapshot.v1Index != null) { 1112 + final v1FromV2 = SavedFeedsPref(saved: v2.savedUris, pinned: v2.pinnedUris); 1113 + snapshot.preferences[snapshot.v1Index!] = v1FromV2.toJson(); 1114 + } 1115 + } else if (v1 != null) { 1116 + final json = v1.toJson(); 1117 + if (snapshot.v1Index != null) { 1118 + snapshot.preferences[snapshot.v1Index!] = json; 1119 + } else { 1120 + snapshot.preferences.add(json); 1121 + } 1122 + } 1123 + } 1124 + 1125 + List<SavedFeedItem> _upsertV2Item(List<SavedFeedItem> items, String feedUri, bool pinned) { 1126 + final updated = List<SavedFeedItem>.from(items); 1127 + final index = updated.indexWhere((item) => item.value == feedUri); 1128 + if (index >= 0) { 1129 + final existing = updated[index]; 1130 + updated[index] = SavedFeedItem(value: feedUri, pinned: pinned, id: existing.id); 1131 + } else { 1132 + updated.add(SavedFeedItem(value: feedUri, pinned: pinned, id: _uuid.v4())); 1133 + } 1134 + return updated; 1135 + } 1136 + 1137 + List<SavedFeedItem> _reorderV2Items(List<SavedFeedItem> items, List<String> orderedUris) { 1138 + final itemByUri = {for (final item in items) item.value: item}; 1139 + final reordered = <SavedFeedItem>[]; 1140 + 1141 + for (final uri in orderedUris) { 1142 + final item = itemByUri[uri]; 1143 + if (item != null) { 1144 + reordered.add(item); 1145 + } 1146 + } 1147 + 1148 + for (final item in items) { 1149 + if (!reordered.any((existing) => existing.value == item.value)) { 1150 + reordered.add(item); 1151 + } 1152 + } 1153 + 1154 + return reordered; 1155 + } 1156 + 1157 + Future<void> _clearLocalModifications(List<String> uris, String ownerDid) async { 1158 + await _dao.db.transaction(() async { 1159 + for (final uri in uris) { 1160 + await _dao.clearLocalModification(uri, ownerDid); 1161 + } 1162 + }); 1163 + } 1075 1164 } 1076 1165 1077 1166 /// Helper class for representing a partial feed update during merge. ··· 1098 1187 final bool isPinned; 1099 1188 final int sortOrder; 1100 1189 } 1190 + 1191 + class _SavedFeedsPreferencesSnapshot { 1192 + const _SavedFeedsPreferencesSnapshot({ 1193 + required this.preferences, 1194 + required this.v2, 1195 + required this.v1, 1196 + required this.v2Index, 1197 + required this.v1Index, 1198 + }); 1199 + 1200 + final List<Map<String, dynamic>> preferences; 1201 + final SavedFeedsPrefV2? v2; 1202 + final SavedFeedsPref? v1; 1203 + final int? v2Index; 1204 + final int? v1Index; 1205 + }
+3 -2
lib/src/features/feeds/presentation/screens/bookmarks_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:lazurite/src/core/utils/error_message.dart'; 3 4 import 'package:lazurite/src/core/widgets/error_view.dart'; 4 5 import 'package:lazurite/src/core/widgets/loading_view.dart'; 5 6 import 'package:lazurite/src/core/widgets/pull_to_refresh_wrapper.dart'; ··· 53 54 if (mounted) { 54 55 ScaffoldMessenger.of(context).showSnackBar( 55 56 SnackBar( 56 - content: Text('Failed to refresh bookmarks: ${e.toString()}'), 57 + content: Text('Failed to refresh bookmarks: ${errorMessage(e)}'), 57 58 action: SnackBarAction(label: 'Retry', onPressed: _refresh), 58 59 ), 59 60 ); ··· 117 118 loading: () => const LoadingView(), 118 119 error: (e, st) => ErrorView( 119 120 title: 'Failed to load bookmarks', 120 - message: e.toString(), 121 + message: errorMessage(e), 121 122 onRetry: _refresh, 122 123 ), 123 124 ),
+2 -1
lib/src/features/feeds/presentation/screens/feed_management_screen.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/src/core/utils/error_message.dart'; 6 7 import 'package:lazurite/src/core/widgets/error_view.dart'; 7 8 import 'package:lazurite/src/core/widgets/loading_view.dart'; 8 9 import 'package:lazurite/src/features/feeds/application/feed_providers.dart'; ··· 114 115 loading: () => const LoadingView(), 115 116 error: (err, stack) => ErrorView( 116 117 title: 'Failed to load feeds', 117 - message: err.toString(), 118 + message: errorMessage(err), 118 119 onRetry: () => ref.invalidate(allFeedsProvider), 119 120 ), 120 121 ),
+2 -1
lib/src/features/feeds/presentation/screens/feed_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:lazurite/src/core/animations/animation_utils.dart'; 4 + import 'package:lazurite/src/core/utils/error_message.dart'; 4 5 import 'package:lazurite/src/core/widgets/error_view.dart'; 5 6 import 'package:lazurite/src/core/widgets/loading_view.dart'; 6 7 import 'package:lazurite/src/core/widgets/pull_to_refresh_wrapper.dart'; ··· 142 143 error: (err, stack) => ErrorView( 143 144 key: const ValueKey('error'), 144 145 title: 'Failed to load feed', 145 - message: err.toString(), 146 + message: errorMessage(err), 146 147 onRetry: () => ref.read(feedContentProvider(activeFeedUri).notifier).refresh(), 147 148 ), 148 149 ),
+2 -1
lib/src/features/feeds/presentation/widgets/feed_preview_modal.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:lazurite/src/core/utils/error_message.dart'; 3 4 import 'package:lazurite/src/core/widgets/error_view.dart'; 4 5 import 'package:lazurite/src/core/widgets/loading_view.dart'; 5 6 import 'package:lazurite/src/features/feeds/application/feed_content_notifier.dart'; ··· 141 142 loading: () => const LoadingView(), 142 143 error: (err, stack) => ErrorView( 143 144 title: 'Failed to load preview', 144 - message: err.toString(), 145 + message: errorMessage(err), 145 146 onRetry: () { 146 147 ref.read(feedContentProvider(widget.feedUri).notifier).refresh(); 147 148 },
+2 -1
lib/src/features/notifications/presentation/notifications_screen.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 4 4 import 'package:lazurite/src/core/animations/animation_utils.dart'; 5 + import 'package:lazurite/src/core/utils/error_message.dart'; 5 6 import 'package:lazurite/src/core/widgets/error_view.dart'; 6 7 import 'package:lazurite/src/core/widgets/pull_to_refresh_wrapper.dart'; 7 8 import 'package:lazurite/src/features/auth/application/auth_providers.dart'; ··· 123 124 error: (err, stack) => ErrorView( 124 125 key: const ValueKey('error'), 125 126 title: 'Failed to load notifications', 126 - message: err.toString(), 127 + message: errorMessage(err), 127 128 onRetry: () => ref.read(notificationsProvider.notifier).refresh(), 128 129 ), 129 130 ),
+2
lib/src/features/profile/presentation/followers_page.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/src/core/utils/error_message.dart'; 4 5 import 'package:lazurite/src/core/widgets/actor_row.dart'; 5 6 import 'package:lazurite/src/core/widgets/error_view.dart'; 6 7 import 'package:lazurite/src/core/widgets/loading_view.dart'; ··· 48 49 loading: () => const LoadingView(), 49 50 error: (error, stack) => ErrorView( 50 51 title: 'Failed to load followers', 52 + message: errorMessage(error), 51 53 onRetry: () => ref.invalidate(followersProvider(widget.did)), 52 54 ), 53 55 data: (followers) {
+2
lib/src/features/profile/presentation/following_page.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/src/core/utils/error_message.dart'; 4 5 import 'package:lazurite/src/core/widgets/actor_row.dart'; 5 6 import 'package:lazurite/src/core/widgets/error_view.dart'; 6 7 import 'package:lazurite/src/core/widgets/loading_view.dart'; ··· 48 49 loading: () => const LoadingView(), 49 50 error: (error, stack) => ErrorView( 50 51 title: 'Failed to load following', 52 + message: errorMessage(error), 51 53 onRetry: () => ref.invalidate(followingProvider(widget.did)), 52 54 ), 53 55 data: (following) {
+2 -1
lib/src/features/profile/presentation/profile_screen.dart
··· 3 3 import 'package:go_router/go_router.dart'; 4 4 import 'package:lazurite/src/app/routes.dart'; 5 5 import 'package:lazurite/src/core/constants/layout_constants.dart'; 6 + import 'package:lazurite/src/core/utils/error_message.dart'; 6 7 import 'package:lazurite/src/core/widgets/error_view.dart'; 7 8 import 'package:lazurite/src/core/widgets/feed_post_card.dart'; 8 9 import 'package:lazurite/src/core/widgets/loading_view.dart'; ··· 278 279 appBar: AppBar(title: const Text('Profile')), 279 280 body: ErrorView( 280 281 title: 'Failed to load profile', 281 - message: error.toString(), 282 + message: errorMessage(error), 282 283 onRetry: () => ref.read(profileProvider(widget.did).notifier).refresh(), 283 284 ), 284 285 ),
+2 -1
lib/src/features/search/presentation/search_screen.dart
··· 5 5 import 'package:go_router/go_router.dart'; 6 6 import 'package:lazurite/src/core/constants/layout_constants.dart'; 7 7 import 'package:lazurite/src/core/domain/post.dart'; 8 + import 'package:lazurite/src/core/utils/error_message.dart'; 8 9 import 'package:lazurite/src/core/widgets/loading_view.dart'; 9 10 import 'package:lazurite/src/features/feeds/presentation/widgets/post/post_embeds.dart'; 10 11 import 'package:lazurite/src/features/search/application/search_providers.dart'; ··· 383 384 children: [ 384 385 const Icon(Icons.error_outline, size: 48), 385 386 const SizedBox(height: 16), 386 - Text('Error: $error'), 387 + Text(errorMessage(error)), 387 388 const SizedBox(height: 16), 388 389 ElevatedButton(onPressed: onRetry, child: const Text('Retry')), 389 390 ],
+3 -2
lib/src/features/thread/presentation/thread_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 + import 'package:lazurite/src/core/utils/error_message.dart'; 3 4 import 'package:lazurite/src/core/widgets/error_view.dart'; 4 5 import 'package:lazurite/src/core/widgets/loading_view.dart'; 5 6 import 'package:lazurite/src/features/feeds/presentation/screens/widgets/feed_post_card.dart'; ··· 85 86 loading: () => const LoadingView(), 86 87 error: (err, stack) => ErrorView( 87 88 title: 'Could not load thread', 88 - message: err.toString(), 89 + message: errorMessage(err), 89 90 onRetry: () => ref.refresh(threadProvider(widget.postUri)), 90 91 ), 91 92 data: (thread) { ··· 352 353 loading: () => const LoadingView(), 353 354 error: (err, stack) => ErrorView( 354 355 title: 'Thread cache unavailable', 355 - message: err.toString(), 356 + message: errorMessage(err), 356 357 onRetry: () => ref.refresh(threadProvider(widget.postUri)), 357 358 ), 358 359 );
+57
test/src/core/utils/error_message_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/src/core/utils/error_message.dart'; 3 + import 'package:lazurite/src/features/dms/domain/outbox_error.dart'; 4 + import 'package:lazurite/src/infrastructure/auth/oauth_exceptions.dart'; 5 + import 'package:lazurite/src/infrastructure/network/network_failure.dart'; 6 + 7 + void main() { 8 + group('errorMessage', () { 9 + test('returns network-friendly messages', () { 10 + expect( 11 + errorMessage(const ConnectionFailure()), 12 + 'Network error. Check your connection and try again.', 13 + ); 14 + expect( 15 + errorMessage(const AuthFailure(requiresReauth: true)), 16 + 'Session expired. Please sign in again.', 17 + ); 18 + expect( 19 + errorMessage(const RateLimitFailure(retryAfter: Duration(seconds: 12))), 20 + 'Too many requests. Try again in 12s.', 21 + ); 22 + expect( 23 + errorMessage(const ClientFailure(statusCode: 404)), 24 + 'Not found. The item may have been deleted.', 25 + ); 26 + expect( 27 + errorMessage(const ServerFailure(statusCode: 500)), 28 + 'Server error. Please try again later.', 29 + ); 30 + expect( 31 + errorMessage(const DecodeFailure()), 32 + 'We received an unexpected response. Please try again.', 33 + ); 34 + }); 35 + 36 + test('returns OAuth and validation messages', () { 37 + const oauthError = InvalidGrantException(errorDescription: 'Token expired'); 38 + expect(errorMessage(oauthError), 'Token expired'); 39 + 40 + const validation = ValidationError(message: 'Message too long'); 41 + expect(errorMessage(validation), 'Message too long'); 42 + }); 43 + 44 + test('handles format and auth state errors', () { 45 + expect(errorMessage(const FormatException('Invalid response')), 'Invalid response'); 46 + expect( 47 + errorMessage(StateError('Must be authenticated to continue')), 48 + 'Please sign in to continue.', 49 + ); 50 + }); 51 + 52 + test('falls back to default message for unknown errors', () { 53 + expect(errorMessage(Exception('oops')), 'Something went wrong. Please try again.'); 54 + expect(errorMessage(null), 'Something went wrong. Please try again.'); 55 + }); 56 + }); 57 + }
+2 -1
test/src/features/composer/infrastructure/draft_repository_test.dart
··· 6 6 import 'package:drift/native.dart'; 7 7 import 'package:flutter_test/flutter_test.dart'; 8 8 import 'package:lazurite/src/core/auth/session_model.dart'; 9 + import 'package:lazurite/src/core/utils/error_message.dart'; 9 10 import 'package:lazurite/src/features/composer/domain/draft.dart' as composer; 10 11 import 'package:lazurite/src/features/composer/infrastructure/draft_repository.dart'; 11 12 import 'package:lazurite/src/infrastructure/db/app_database.dart'; ··· 187 188 188 189 final failed = await repository.getDraft(draft.id); 189 190 expect(failed.status, composer.DraftStatus.failed); 190 - expect(failed.errorMessage, contains('publish failed')); 191 + expect(failed.errorMessage, errorMessage(Exception('publish failed'))); 191 192 expect(failed.media.single.uploadCid, 'cid-test'); 192 193 }); 193 194
+164
test/src/features/feeds/infrastructure/feed_repository_sync_test.dart
··· 263 263 264 264 final feed = await db.savedFeedsDao.getFeed(feedUri, ownerDid); 265 265 expect(feed, isNotNull, reason: 'Local feed should be saved'); 266 + expect(feed!.localUpdatedAt, isNull, reason: 'Local modifications should be cleared'); 266 267 267 268 final queueItems = await db.preferenceSyncQueueDao.getPendingItems(ownerDid); 268 269 expect(queueItems, isEmpty, reason: 'Queue should be empty after successful sync'); ··· 336 337 337 338 final queueItems = await db.preferenceSyncQueueDao.getPendingItems(ownerDid); 338 339 expect(queueItems, isEmpty, reason: 'Queue should be empty after successful sync'); 340 + }); 341 + 342 + test('saveFeed unpins feeds in legacy preferences when pin is false', () async { 343 + const feedUri = 'at://did:plc:unpin/app.bsky.feed.generator/test'; 344 + 345 + final currentPrefs = { 346 + 'preferences': [ 347 + { 348 + '\$type': 'app.bsky.actor.defs#savedFeedsPref', 349 + 'saved': [feedUri], 350 + 'pinned': [feedUri], 351 + }, 352 + ], 353 + }; 354 + 355 + final feedMetadata = { 356 + 'view': { 357 + 'uri': feedUri, 358 + 'cid': 'bafytest', 359 + 'did': 'did:web:feedgen.test', 360 + 'displayName': 'Test Feed', 361 + 'description': 'Test feed description', 362 + 'avatar': 'avatar.jpg', 363 + 'creator': {'did': 'did:plc:unpin', 'handle': 'unpin.user'}, 364 + 'likeCount': 1, 365 + }, 366 + }; 367 + 368 + when( 369 + () => mockApi.call('app.bsky.feed.getFeedGenerator', params: {'feed': feedUri}), 370 + ).thenAnswer((_) async => feedMetadata); 371 + when( 372 + () => mockApi.call('app.bsky.actor.getPreferences'), 373 + ).thenAnswer((_) async => currentPrefs); 374 + 375 + final capturedBodies = <Map<String, dynamic>>[]; 376 + when( 377 + () => mockApi.call('app.bsky.actor.putPreferences', body: any(named: 'body')), 378 + ).thenAnswer((invocation) async { 379 + capturedBodies.add(invocation.namedArguments[#body] as Map<String, dynamic>); 380 + return {}; 381 + }); 382 + 383 + await repository.saveFeed(feedUri, ownerDid, pin: false); 384 + 385 + final prefs = capturedBodies.single['preferences'] as List<dynamic>; 386 + final savedPref = prefs.cast<Map<String, dynamic>>().firstWhere( 387 + (pref) => pref['\$type'] == 'app.bsky.actor.defs#savedFeedsPref', 388 + ); 389 + final pinned = List<String>.from(savedPref['pinned'] as List); 390 + 391 + expect(pinned.contains(feedUri), false, reason: 'Feed should be unpinned remotely'); 392 + }); 393 + 394 + test('saveFeed updates pinned state in V2 preferences', () async { 395 + const feedUri = 'at://did:plc:v2/app.bsky.feed.generator/test'; 396 + 397 + final currentPrefs = { 398 + 'preferences': [ 399 + { 400 + '\$type': 'app.bsky.actor.defs#savedFeedsPrefV2', 401 + 'items': [ 402 + {'value': feedUri, 'pinned': true, 'id': 'item-1'}, 403 + ], 404 + }, 405 + { 406 + '\$type': 'app.bsky.actor.defs#savedFeedsPref', 407 + 'saved': [feedUri], 408 + 'pinned': [feedUri], 409 + }, 410 + ], 411 + }; 412 + 413 + final feedMetadata = { 414 + 'view': { 415 + 'uri': feedUri, 416 + 'cid': 'bafytest', 417 + 'did': 'did:web:feedgen.test', 418 + 'displayName': 'Test Feed V2', 419 + 'description': 'Test feed description', 420 + 'avatar': 'avatar.jpg', 421 + 'creator': {'did': 'did:plc:v2', 'handle': 'v2.user'}, 422 + 'likeCount': 1, 423 + }, 424 + }; 425 + 426 + when( 427 + () => mockApi.call('app.bsky.feed.getFeedGenerator', params: {'feed': feedUri}), 428 + ).thenAnswer((_) async => feedMetadata); 429 + when( 430 + () => mockApi.call('app.bsky.actor.getPreferences'), 431 + ).thenAnswer((_) async => currentPrefs); 432 + 433 + final capturedBodies = <Map<String, dynamic>>[]; 434 + when( 435 + () => mockApi.call('app.bsky.actor.putPreferences', body: any(named: 'body')), 436 + ).thenAnswer((invocation) async { 437 + capturedBodies.add(invocation.namedArguments[#body] as Map<String, dynamic>); 438 + return {}; 439 + }); 440 + 441 + await repository.saveFeed(feedUri, ownerDid, pin: false); 442 + 443 + final prefs = capturedBodies.single['preferences'] as List<dynamic>; 444 + final v2Pref = prefs.cast<Map<String, dynamic>>().firstWhere( 445 + (pref) => pref['\$type'] == 'app.bsky.actor.defs#savedFeedsPrefV2', 446 + ); 447 + final items = v2Pref['items'] as List<dynamic>; 448 + final item = items.cast<Map<String, dynamic>>().first; 449 + 450 + expect(item['id'], 'item-1', reason: 'V2 item id should be preserved'); 451 + expect(item['pinned'], false, reason: 'Pinned flag should be updated'); 452 + }); 453 + 454 + test('reorderFeeds clears local modifications after successful sync', () async { 455 + const feedUriA = 'at://did:plc:reorder/app.bsky.feed.generator/a'; 456 + const feedUriB = 'at://did:plc:reorder/app.bsky.feed.generator/b'; 457 + 458 + final now = DateTime.now(); 459 + await db.savedFeedsDao.upsertFeed( 460 + SavedFeedsCompanion.insert( 461 + uri: feedUriA, 462 + displayName: 'Feed A', 463 + creatorDid: 'did:plc:reorder', 464 + sortOrder: 0, 465 + lastSynced: now, 466 + ownerDid: ownerDid, 467 + ), 468 + ); 469 + await db.savedFeedsDao.upsertFeed( 470 + SavedFeedsCompanion.insert( 471 + uri: feedUriB, 472 + displayName: 'Feed B', 473 + creatorDid: 'did:plc:reorder', 474 + sortOrder: 1, 475 + lastSynced: now, 476 + ownerDid: ownerDid, 477 + ), 478 + ); 479 + 480 + final currentPrefs = { 481 + 'preferences': [ 482 + { 483 + '\$type': 'app.bsky.actor.defs#savedFeedsPref', 484 + 'saved': [feedUriA, feedUriB], 485 + 'pinned': <String>[], 486 + }, 487 + ], 488 + }; 489 + 490 + when( 491 + () => mockApi.call('app.bsky.actor.getPreferences'), 492 + ).thenAnswer((_) async => currentPrefs); 493 + when( 494 + () => mockApi.call('app.bsky.actor.putPreferences', body: any(named: 'body')), 495 + ).thenAnswer((_) async => {}); 496 + 497 + await repository.reorderFeeds([feedUriB, feedUriA], ownerDid); 498 + 499 + final feedA = await db.savedFeedsDao.getFeed(feedUriA, ownerDid); 500 + final feedB = await db.savedFeedsDao.getFeed(feedUriB, ownerDid); 501 + expect(feedA!.localUpdatedAt, isNull); 502 + expect(feedB!.localUpdatedAt, isNull); 339 503 }); 340 504 341 505 test('rolls back local save when queue enqueue fails', () async {
+5 -2
test/src/features/search/presentation/search_screen_test.dart
··· 8 8 import 'package:lazurite/src/features/search/presentation/search_screen.dart'; 9 9 import 'package:lazurite/src/features/search/presentation/widgets/search_bar_widget.dart'; 10 10 import 'package:lazurite/src/infrastructure/db/app_database.dart' hide Post; 11 + import 'package:lazurite/src/infrastructure/network/network_failure.dart'; 11 12 import 'package:mocktail/mocktail.dart'; 12 13 13 14 class MockSearchRepository extends Mock implements SearchRepository {} ··· 171 172 172 173 testWidgets('retry on error', (tester) async { 173 174 const query = 'error'; 174 - when(() => mockRepository.searchPosts(query, cursor: null)).thenThrow('Network Error'); 175 + when( 176 + () => mockRepository.searchPosts(query, cursor: null), 177 + ).thenThrow(const ConnectionFailure()); 175 178 when( 176 179 () => mockRepository.searchActors(query, cursor: null), 177 180 ).thenAnswer((_) async => const PaginatedResult(items: [], cursor: null)); ··· 179 182 await tester.pumpWidget(createSubject(initialQuery: query)); 180 183 await tester.pumpAndSettle(); 181 184 182 - expect(find.text('Error: Network Error'), findsOneWidget); 185 + expect(find.text('Network error. Check your connection and try again.'), findsOneWidget); 183 186 expect(find.text('Retry'), findsOneWidget); 184 187 185 188 await tester.tap(find.text('Retry'));