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

Configure Feed

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

feat: atproto persisted saves

+699 -49
+49 -4
docs/BUGS.md
··· 15 15 - [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 16 16 - [x] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) 17 17 - [x] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) 18 - - [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert) 19 - - [ ] [12. Delete Post — Remove from Feed](#12-delete-post--remove-from-feed) 18 + - [ ] [11. Saved Posts — Cloud Save via AT Protocol](#11-saved-posts--cloud-save-via-at-protocol) 19 + - [ ] [12. Failed Action Snackbar with Revert](#12-failed-action-snackbar-with-revert) 20 + - [ ] [13. Delete Post — Remove from Feed](#13-delete-post--remove-from-feed) 20 21 21 22 ## 1. Post Thread Screen 22 23 ··· 253 254 - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — use the passed 254 255 count instead of hardcoded `0` 255 256 256 - ## 11. Failed Action Snackbar with Revert 257 + ## 11. Saved Posts — Cloud Save via AT Protocol 258 + 259 + **Status:** Not implemented — "Save to Bluesky" option is disabled with "Coming soon" placeholder. 260 + 261 + **Problem:** The save menu in `PostActionBar._showSaveOptions()` has a disabled "Save to 262 + Bluesky" option. The `bluesky` package already exposes a bookmark API 263 + (`app.bsky.bookmark.*`) but it is not wired up. Currently all saves are local-only. 264 + 265 + **Fix:** 266 + 267 + - Add bookmark methods to `PostActionRepository` using the existing `_bluesky.bookmark` 268 + service: 269 + - `createBookmark({uri, cid})` → `_bluesky.bookmark.createBookmark(uri, cid)` 270 + - `deleteBookmark({uri})` → `_bluesky.bookmark.deleteBookmark(uri)` 271 + - `getBookmarks({limit, cursor})` → `_bluesky.bookmark.getBookmarks(limit, cursor)` 272 + - Add `cloudSave` and `cloudUnsave` methods to `SavedPostsCubit`: 273 + - Call `PostActionRepository.createBookmark` / `deleteBookmark`. 274 + - On success, upsert the local DB row with `saveType: 'cloud'` (or `'both'` if already 275 + saved locally). On cloud unsave, downgrade `saveType` to `'local'` if a local save 276 + exists, or delete the row entirely. 277 + - Use optimistic UI: update the icon immediately, revert on failure. 278 + - Enable the "Save to Bluesky" / "Remove from Bluesky" option in 279 + `PostActionBar._showSaveOptions()` and wire it to `SavedPostsCubit.cloudSave` / 280 + `cloudUnsave` via a new callback. 281 + - Distinguish cloud vs local saves visually: 282 + - Local-only: amber/gold bookmark icon. 283 + - Cloud (or both): primary/blue bookmark icon. 284 + - `PostActionBar` already receives `isSaved`; extend it with a `saveType` parameter 285 + (or similar) so the icon color reflects the save type. 286 + - Add a one-time sync on login: call `getBookmarks` (paginated) and merge results into 287 + the local DB so cloud saves made on other clients appear. Mark these as `saveType: 288 + 'cloud'`. 289 + 290 + **Files:** 291 + 292 + - Edit: `lib/features/feed/data/post_action_repository.dart` — add `createBookmark`, 293 + `deleteBookmark`, `getBookmarks` methods 294 + - Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — add `cloudSave`, 295 + `cloudUnsave`, `syncCloudBookmarks` methods; handle `saveType` transitions 296 + - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — enable cloud 297 + save option, accept `saveType` parameter, update icon color logic 298 + - Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass 299 + `saveType` and cloud save/unsave callbacks to `PostActionBar` 300 + 301 + ## 12. Failed Action Snackbar with Revert 257 302 258 303 **Status:** Partially implemented — rollback works but snackbar is basic. 259 304 ··· 278 323 - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — show loading 279 324 state visually on like/repost buttons 280 325 281 - ## 12. Delete Post — Remove from Feed 326 + ## 13. Delete Post — Remove from Feed 282 327 283 328 **Status:** Incomplete — post is deleted on the server but remains visible in the feed. 284 329
+6
lib/core/database/app_database.dart
··· 255 255 return (delete(savedPosts)..where((s) => s.accountDid.equals(accountDid))).go(); 256 256 } 257 257 258 + Future<bool> updateSaveType(String accountDid, String postUri, String saveType) async { 259 + final query = update(savedPosts)..where((s) => s.accountDid.equals(accountDid) & s.postUri.equals(postUri)); 260 + final rowsAffected = await query.write(SavedPostsCompanion(saveType: Value(saveType))); 261 + return rowsAffected > 0; 262 + } 263 + 258 264 Stream<List<SavedPostEntry>> watchSavedPosts(String accountDid) { 259 265 return (select(savedPosts) 260 266 ..where((s) => s.accountDid.equals(accountDid))
+114 -5
lib/features/feed/cubit/saved_posts_cubit.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:convert'; 2 3 4 + import 'package:atproto_core/atproto_core.dart'; 5 + import 'package:bluesky/app_bsky_bookmark_defs.dart'; 3 6 import 'package:drift/drift.dart'; 4 7 import 'package:equatable/equatable.dart'; 5 8 import 'package:flutter_bloc/flutter_bloc.dart'; 6 9 import 'package:lazurite/core/database/app_database.dart'; 7 10 import 'package:lazurite/core/logging/app_logger.dart'; 11 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 8 12 9 13 class SavedPostsState extends Equatable { 10 14 const SavedPostsState({ ··· 51 55 enum SavedPostsStatus { initial, loading, loaded, error } 52 56 53 57 class SavedPostsCubit extends Cubit<SavedPostsState> { 54 - SavedPostsCubit({required AppDatabase database, required String accountDid}) 55 - : _database = database, 56 - _accountDid = accountDid, 57 - super(const SavedPostsState()) { 58 + SavedPostsCubit({ 59 + required AppDatabase database, 60 + required String accountDid, 61 + required PostActionRepository postActionRepository, 62 + }) : _database = database, 63 + _accountDid = accountDid, 64 + _postActionRepository = postActionRepository, 65 + super(const SavedPostsState()) { 58 66 _init(); 59 67 } 60 68 61 69 final AppDatabase _database; 62 70 final String _accountDid; 71 + final PostActionRepository _postActionRepository; 63 72 StreamSubscription<Map<String, String>>? _savedUrisSubscription; 64 73 65 74 void _init() { ··· 73 82 log.e('Error watching saved post URIs', error: error); 74 83 }, 75 84 ); 85 + unawaited(syncCloudBookmarks()); 76 86 } 77 87 78 88 Future<void> loadSavedPosts() async { ··· 81 91 try { 82 92 final posts = await _database.getSavedPosts(_accountDid); 83 93 final uris = posts.map((p) => p.postUri).toSet(); 94 + final typeByUri = {for (final p in posts) p.postUri: p.saveType}; 84 95 85 - emit(state.copyWith(status: SavedPostsStatus.loaded, savedPosts: posts, savedUris: uris)); 96 + emit( 97 + state.copyWith(status: SavedPostsStatus.loaded, savedPosts: posts, savedUris: uris, saveTypeByUri: typeByUri), 98 + ); 86 99 } catch (error) { 87 100 log.e('Failed to load saved posts', error: error); 88 101 emit(state.copyWith(status: SavedPostsStatus.error, error: 'Failed to load saved posts')); ··· 152 165 153 166 void clearError() { 154 167 emit(state.copyWith(error: null)); 168 + } 169 + 170 + Future<bool> cloudSave({required String postUri, required String cid, required String postJson}) async { 171 + final currentType = state.saveTypeForUri(postUri); 172 + if (currentType == 'cloud' || currentType == 'both') return true; 173 + 174 + final isLocalSaved = currentType == 'local'; 175 + try { 176 + if (isLocalSaved) { 177 + await _database.updateSaveType(_accountDid, postUri, 'both'); 178 + } else { 179 + await _database.savePost( 180 + SavedPostsCompanion( 181 + accountDid: Value(_accountDid), 182 + postUri: Value(postUri), 183 + postJson: Value(postJson), 184 + saveType: const Value('cloud'), 185 + savedAt: Value(DateTime.now()), 186 + ), 187 + ); 188 + } 189 + await _postActionRepository.createBookmark(uri: AtUri.parse(postUri), cid: cid); 190 + return true; 191 + } catch (error) { 192 + log.e('Failed to cloud save post', error: error); 193 + if (isLocalSaved) { 194 + await _database.updateSaveType(_accountDid, postUri, 'local'); 195 + } else { 196 + await _database.unsavePost(_accountDid, postUri); 197 + } 198 + emit(state.copyWith(error: 'Failed to save post to Bluesky')); 199 + return false; 200 + } 201 + } 202 + 203 + Future<bool> cloudUnsave(String postUri) async { 204 + final currentType = state.saveTypeForUri(postUri); 205 + if (currentType == null || currentType == 'local') return true; 206 + 207 + final existingEntry = await _database.getSavedPost(_accountDid, postUri); 208 + try { 209 + if (currentType == 'both') { 210 + await _database.updateSaveType(_accountDid, postUri, 'local'); 211 + } else { 212 + await _database.unsavePost(_accountDid, postUri); 213 + } 214 + await _postActionRepository.deleteBookmark(uri: AtUri.parse(postUri)); 215 + return true; 216 + } catch (error) { 217 + log.e('Failed to cloud unsave post', error: error); 218 + if (currentType == 'both') { 219 + await _database.updateSaveType(_accountDid, postUri, 'both'); 220 + } else if (existingEntry != null) { 221 + await _database.savePost( 222 + SavedPostsCompanion( 223 + accountDid: Value(_accountDid), 224 + postUri: Value(postUri), 225 + postJson: Value(existingEntry.postJson), 226 + saveType: const Value('cloud'), 227 + savedAt: Value(existingEntry.savedAt), 228 + ), 229 + ); 230 + } 231 + emit(state.copyWith(error: 'Failed to remove post from Bluesky')); 232 + return false; 233 + } 234 + } 235 + 236 + Future<void> syncCloudBookmarks() async { 237 + try { 238 + String? cursor; 239 + do { 240 + final output = await _postActionRepository.getBookmarks(limit: 100, cursor: cursor); 241 + for (final bookmark in output.bookmarks) { 242 + final postUri = bookmark.subject.uri.toString(); 243 + final postJson = bookmark.item.isPostView ? jsonEncode(bookmark.item.postView!.toJson()) : '{}'; 244 + final existing = await _database.getSavedPost(_accountDid, postUri); 245 + if (existing == null) { 246 + await _database.savePost( 247 + SavedPostsCompanion( 248 + accountDid: Value(_accountDid), 249 + postUri: Value(postUri), 250 + postJson: Value(postJson), 251 + saveType: const Value('cloud'), 252 + savedAt: Value(bookmark.createdAt ?? DateTime.now()), 253 + ), 254 + ); 255 + } else if (existing.saveType == 'local') { 256 + await _database.updateSaveType(_accountDid, postUri, 'both'); 257 + } 258 + } 259 + cursor = output.cursor; 260 + } while (cursor != null); 261 + } catch (error) { 262 + log.e('Failed to sync cloud bookmarks', error: error); 263 + } 155 264 } 156 265 157 266 @override
+14
lib/features/feed/data/post_action_repository.dart
··· 1 1 import 'package:atproto/com_atproto_repo_strongref.dart'; 2 2 import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 3 4 import 'package:bluesky/bluesky.dart'; 4 5 5 6 class PostActionRepository { ··· 38 39 Future<void> deletePost({required String postUri}) async { 39 40 final rkey = _extractRkey(postUri); 40 41 await _bluesky.feed.post.delete(rkey: rkey); 42 + } 43 + 44 + Future<void> createBookmark({required AtUri uri, required String cid}) async { 45 + await _bluesky.bookmark.createBookmark(uri: uri, cid: cid); 46 + } 47 + 48 + Future<void> deleteBookmark({required AtUri uri}) async { 49 + await _bluesky.bookmark.deleteBookmark(uri: uri); 50 + } 51 + 52 + Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 53 + final response = await _bluesky.bookmark.getBookmarks(limit: limit, cursor: cursor); 54 + return response.data; 41 55 } 42 56 43 57 String _extractRkey(String uri) {
+6 -2
lib/features/feed/presentation/saved_posts_screen.dart
··· 7 7 import 'package:lazurite/core/database/app_database.dart'; 8 8 import 'package:lazurite/core/logging/app_logger.dart'; 9 9 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 10 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 10 11 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 11 12 import 'package:share_plus/share_plus.dart'; 12 13 ··· 18 19 @override 19 20 Widget build(BuildContext context) { 20 21 return BlocProvider( 21 - create: (context) => 22 - SavedPostsCubit(database: context.read<AppDatabase>(), accountDid: accountDid)..loadSavedPosts(), 22 + create: (context) => SavedPostsCubit( 23 + database: context.read<AppDatabase>(), 24 + accountDid: accountDid, 25 + postActionRepository: context.read<PostActionRepository>(), 26 + )..loadSavedPosts(), 23 27 child: const _SavedPostsContent(), 24 28 ); 25 29 }
+24 -8
lib/features/feed/presentation/widgets/post_action_bar.dart
··· 13 13 required this.isLiked, 14 14 required this.isReposted, 15 15 required this.isSaved, 16 + this.saveType, 16 17 required this.postUri, 17 18 this.postCid, 18 19 this.onReply, ··· 22 23 this.onShare, 23 24 this.onSave, 24 25 this.onLongPressSave, 26 + this.onCloudSave, 27 + this.onCloudUnsave, 25 28 this.onMore, 26 29 this.isLoadingLike = false, 27 30 this.isLoadingRepost = false, ··· 34 37 final bool isLiked; 35 38 final bool isReposted; 36 39 final bool isSaved; 40 + final String? saveType; 37 41 final String postUri; 38 42 final String? postCid; 39 43 final VoidCallback? onReply; ··· 43 47 final VoidCallback? onShare; 44 48 final VoidCallback? onSave; 45 49 final VoidCallback? onLongPressSave; 50 + final VoidCallback? onCloudSave; 51 + final VoidCallback? onCloudUnsave; 46 52 final VoidCallback? onMore; 47 53 final bool isLoadingLike; 48 54 final bool isLoadingRepost; ··· 86 92 onTap: onSave != null ? () => _showSaveOptions(context) : null, 87 93 onLongPress: onLongPressSave, 88 94 color: Theme.of(context).colorScheme.onSurfaceVariant, 89 - activeColor: Colors.amber, 95 + activeColor: (saveType == 'cloud' || saveType == 'both') 96 + ? Theme.of(context).colorScheme.primary 97 + : Colors.amber, 90 98 ), 91 99 _ActionButton( 92 100 icon: Icons.share_outlined, ··· 142 150 143 151 void _showSaveOptions(BuildContext context) { 144 152 HapticFeedback.mediumImpact(); 153 + final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); 154 + final isCloudSaved = saveType == 'cloud' || saveType == 'both'; 145 155 showModalBottomSheet<void>( 146 156 context: context, 147 157 builder: (context) => SafeArea( ··· 150 160 children: [ 151 161 ListTile( 152 162 leading: Icon( 153 - isSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 163 + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, 154 164 color: Colors.amber, 155 165 ), 156 - title: Text(isSaved ? 'Remove local save' : 'Save locally'), 166 + title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), 157 167 onTap: () { 158 168 Navigator.pop(context); 159 169 onSave?.call(); 160 170 }, 161 171 ), 162 172 ListTile( 163 - enabled: false, 164 173 leading: Icon( 165 - Icons.cloud_outlined, 166 - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), 174 + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, 175 + color: Theme.of(context).colorScheme.primary, 167 176 ), 168 - title: const Text('Save to Bluesky'), 169 - subtitle: const Text('Coming soon'), 177 + title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), 178 + onTap: () { 179 + Navigator.pop(context); 180 + if (isCloudSaved) { 181 + onCloudUnsave?.call(); 182 + } else { 183 + onCloudSave?.call(); 184 + } 185 + }, 170 186 ), 171 187 ], 172 188 ),
+23
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 87 87 isLiked: postActionState.isLiked, 88 88 isReposted: postActionState.isReposted, 89 89 isSaved: savedState.isSaved(post.uri.toString()), 90 + saveType: savedState.saveTypeForUri(post.uri.toString()), 90 91 postUri: post.uri.toString(), 91 92 postCid: post.cid, 92 93 isLoadingLike: postActionState.isLoadingLike, ··· 100 101 }, 101 102 onLongPressSave: () { 102 103 unawaited(_onToggleSave(context)); 104 + }, 105 + onCloudSave: () { 106 + unawaited(_onCloudSave(context)); 107 + }, 108 + onCloudUnsave: () { 109 + unawaited(_onCloudUnsave(context)); 103 110 }, 104 111 onMore: () => _showMoreOptions(context), 105 112 ); ··· 153 160 154 161 await HapticFeedback.lightImpact(); 155 162 await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); 163 + } 164 + 165 + Future<void> _onCloudSave(BuildContext context) async { 166 + final cubit = context.read<SavedPostsCubit>(); 167 + final post = feedViewPost.post; 168 + 169 + await HapticFeedback.lightImpact(); 170 + await cubit.cloudSave(postUri: post.uri.toString(), cid: post.cid, postJson: jsonEncode(post.toJson())); 171 + } 172 + 173 + Future<void> _onCloudUnsave(BuildContext context) async { 174 + final cubit = context.read<SavedPostsCubit>(); 175 + final post = feedViewPost.post; 176 + 177 + await HapticFeedback.lightImpact(); 178 + await cubit.cloudUnsave(post.uri.toString()); 156 179 } 157 180 158 181 void _showMoreOptions(BuildContext context) {
+5 -1
lib/main.dart
··· 148 148 SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), 149 149 ), 150 150 BlocProvider( 151 - create: (_) => SavedPostsCubit(database: widget.database, accountDid: accountDid), 151 + create: (_) => SavedPostsCubit( 152 + database: widget.database, 153 + accountDid: accountDid, 154 + postActionRepository: postActionRepository, 155 + ), 152 156 ), 153 157 RepositoryProvider.value(value: feedRepository), 154 158 RepositoryProvider.value(value: searchRepository),
+1 -6
test/features/feed/cubit/post_action_cubit_test.dart
··· 287 287 postActionRepository: mockRepository, 288 288 postUri: testPostUri, 289 289 postCid: testPostCid, 290 - // API says unliked with 0 counts — cache should win 291 290 isLiked: false, 292 291 likeCount: 0, 293 292 cache: cache, ··· 340 339 await cubit.toggleLike(); 341 340 }, 342 341 verify: (cubit) { 343 - // After settling, cache should reflect liked state. 344 - // We verify by re-seeding a new cubit from the same cache. 345 - // The cubit exposes its cache indirectly — just check final state. 346 342 expect(cubit.state.isLiked, isTrue); 347 343 expect(cubit.state.likeUri, testLikeUri); 348 344 expect(cubit.state.isLoadingLike, isFalse); ··· 396 392 await cubit1.toggleLike(); 397 393 await cubit1.close(); 398 394 399 - // Simulate widget recycling: new cubit with stale API data (isLiked=false). 400 395 final cubit2 = PostActionCubit( 401 396 postActionRepository: mockRepository, 402 397 postUri: testPostUri, 403 398 postCid: testPostCid, 404 399 isLiked: false, 405 - likeCount: 3, // stale API count 400 + likeCount: 3, 406 401 cache: cache, 407 402 ); 408 403
+345 -14
test/features/feed/cubit/saved_posts_cubit_test.dart
··· 1 - import 'package:drift/drift.dart' hide isNull; 1 + import 'package:atproto/com_atproto_repo_strongref.dart'; 2 + import 'package:atproto_core/atproto_core.dart'; 3 + import 'package:bluesky/app_bsky_bookmark_defs.dart'; 4 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 5 + import 'package:drift/drift.dart' hide isNull, isNotNull; 2 6 import 'package:drift/native.dart'; 3 7 import 'package:flutter_test/flutter_test.dart'; 4 8 import 'package:lazurite/core/database/app_database.dart'; 5 9 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 10 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockPostActionRepository extends Mock implements PostActionRepository {} 6 14 7 15 void main() { 16 + setUpAll(() { 17 + registerFallbackValue(AtUri.parse('at://did:plc:test/app.bsky.feed.post/fallback')); 18 + }); 19 + 8 20 late AppDatabase database; 21 + late MockPostActionRepository mockRepository; 9 22 10 23 const testAccountDid = 'did:plc:testuser123'; 11 24 const testPostUri1 = 'at://did:plc:author1/app.bsky.feed.post/abc123'; ··· 15 28 16 29 setUp(() async { 17 30 database = AppDatabase(executor: NativeDatabase.memory()); 31 + mockRepository = MockPostActionRepository(); 32 + when( 33 + () => mockRepository.getBookmarks( 34 + limit: any(named: 'limit'), 35 + cursor: any(named: 'cursor'), 36 + ), 37 + ).thenAnswer((_) async => const BookmarkGetBookmarksOutput(bookmarks: [])); 18 38 }); 19 39 20 40 tearDown(() async { ··· 23 43 24 44 group('SavedPostsCubit', () { 25 45 test('initial state has correct values', () { 26 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 46 + final cubit = SavedPostsCubit( 47 + database: database, 48 + accountDid: testAccountDid, 49 + postActionRepository: mockRepository, 50 + ); 27 51 28 52 expect(cubit.state.status, SavedPostsStatus.initial); 29 53 expect(cubit.state.savedPosts, isEmpty); ··· 33 57 34 58 group('loadSavedPosts', () { 35 59 test('loads empty list when no saved posts', () async { 36 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 60 + final cubit = SavedPostsCubit( 61 + database: database, 62 + accountDid: testAccountDid, 63 + postActionRepository: mockRepository, 64 + ); 37 65 38 66 await cubit.loadSavedPosts(); 39 67 ··· 51 79 ), 52 80 ); 53 81 54 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 82 + final cubit = SavedPostsCubit( 83 + database: database, 84 + accountDid: testAccountDid, 85 + postActionRepository: mockRepository, 86 + ); 55 87 56 88 await cubit.loadSavedPosts(); 57 89 ··· 63 95 64 96 group('toggleSave', () { 65 97 test('saves post when not saved', () async { 66 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 98 + final cubit = SavedPostsCubit( 99 + database: database, 100 + accountDid: testAccountDid, 101 + postActionRepository: mockRepository, 102 + ); 67 103 68 104 await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); 69 105 ··· 81 117 ), 82 118 ); 83 119 84 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 120 + final cubit = SavedPostsCubit( 121 + database: database, 122 + accountDid: testAccountDid, 123 + postActionRepository: mockRepository, 124 + ); 85 125 86 126 await cubit.loadSavedPosts(); 87 127 expect(cubit.state.savedPosts.length, 1); ··· 95 135 96 136 group('savePost', () { 97 137 test('saves post when not already saved', () async { 98 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 138 + final cubit = SavedPostsCubit( 139 + database: database, 140 + accountDid: testAccountDid, 141 + postActionRepository: mockRepository, 142 + ); 99 143 100 144 final result = await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); 101 145 ··· 113 157 ), 114 158 ); 115 159 116 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 160 + final cubit = SavedPostsCubit( 161 + database: database, 162 + accountDid: testAccountDid, 163 + postActionRepository: mockRepository, 164 + ); 117 165 118 166 await cubit.loadSavedPosts(); 119 167 final result = await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); ··· 133 181 ), 134 182 ); 135 183 136 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 184 + final cubit = SavedPostsCubit( 185 + database: database, 186 + accountDid: testAccountDid, 187 + postActionRepository: mockRepository, 188 + ); 137 189 138 190 await cubit.loadSavedPosts(); 139 191 expect(cubit.state.savedPosts.length, 1); ··· 155 207 ), 156 208 ); 157 209 158 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 210 + final cubit = SavedPostsCubit( 211 + database: database, 212 + accountDid: testAccountDid, 213 + postActionRepository: mockRepository, 214 + ); 159 215 160 216 await cubit.loadSavedPosts(); 161 217 final posts = await database.getSavedPosts(testAccountDid); ··· 185 241 ), 186 242 ); 187 243 188 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 244 + final cubit = SavedPostsCubit( 245 + database: database, 246 + accountDid: testAccountDid, 247 + postActionRepository: mockRepository, 248 + ); 189 249 190 250 await cubit.loadSavedPosts(); 191 251 expect(cubit.state.savedPosts.length, 2); ··· 199 259 200 260 group('isSaved', () { 201 261 test('returns true when post is saved', () async { 202 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 262 + final cubit = SavedPostsCubit( 263 + database: database, 264 + accountDid: testAccountDid, 265 + postActionRepository: mockRepository, 266 + ); 203 267 204 268 await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); 205 269 ··· 273 337 274 338 group('saveType', () { 275 339 test('toggleSave saves post with local saveType', () async { 276 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 340 + final cubit = SavedPostsCubit( 341 + database: database, 342 + accountDid: testAccountDid, 343 + postActionRepository: mockRepository, 344 + ); 277 345 278 346 await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); 279 347 ··· 283 351 }); 284 352 285 353 test('saveTypeForUri returns local after saving', () async { 286 - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); 354 + final cubit = SavedPostsCubit( 355 + database: database, 356 + accountDid: testAccountDid, 357 + postActionRepository: mockRepository, 358 + ); 287 359 288 360 await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); 289 361 await cubit.loadSavedPosts(); 290 362 291 363 expect(cubit.state.saveTypeForUri(testPostUri1), equals('local')); 292 364 expect(cubit.state.saveTypeForUri(testPostUri2), isNull); 365 + }); 366 + }); 367 + 368 + group('cloudSave', () { 369 + test('inserts post with cloud saveType when not saved', () async { 370 + when( 371 + () => mockRepository.createBookmark( 372 + uri: any(named: 'uri'), 373 + cid: any(named: 'cid'), 374 + ), 375 + ).thenAnswer((_) async {}); 376 + final cubit = SavedPostsCubit( 377 + database: database, 378 + accountDid: testAccountDid, 379 + postActionRepository: mockRepository, 380 + ); 381 + 382 + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); 383 + 384 + expect(result, isTrue); 385 + final posts = await database.getSavedPosts(testAccountDid); 386 + expect(posts.length, 1); 387 + expect(posts.first.saveType, equals('cloud')); 388 + }); 389 + 390 + test('upgrades local save to both when already locally saved', () async { 391 + when( 392 + () => mockRepository.createBookmark( 393 + uri: any(named: 'uri'), 394 + cid: any(named: 'cid'), 395 + ), 396 + ).thenAnswer((_) async {}); 397 + final cubit = SavedPostsCubit( 398 + database: database, 399 + accountDid: testAccountDid, 400 + postActionRepository: mockRepository, 401 + ); 402 + await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); 403 + await cubit.loadSavedPosts(); 404 + 405 + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); 406 + 407 + expect(result, isTrue); 408 + final posts = await database.getSavedPosts(testAccountDid); 409 + expect(posts.first.saveType, equals('both')); 410 + }); 411 + 412 + test('returns true without API call when already cloud saved', () async { 413 + final cubit = SavedPostsCubit( 414 + database: database, 415 + accountDid: testAccountDid, 416 + postActionRepository: mockRepository, 417 + ); 418 + await database.savePost( 419 + SavedPostsCompanion( 420 + accountDid: const Value(testAccountDid), 421 + postUri: const Value(testPostUri1), 422 + postJson: const Value(testPostJson1), 423 + saveType: const Value('cloud'), 424 + savedAt: Value(DateTime.now()), 425 + ), 426 + ); 427 + await cubit.loadSavedPosts(); 428 + 429 + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); 430 + 431 + expect(result, isTrue); 432 + verifyNever( 433 + () => mockRepository.createBookmark( 434 + uri: any(named: 'uri'), 435 + cid: any(named: 'cid'), 436 + ), 437 + ); 438 + }); 439 + 440 + test('reverts and emits error on API failure', () async { 441 + when( 442 + () => mockRepository.createBookmark( 443 + uri: any(named: 'uri'), 444 + cid: any(named: 'cid'), 445 + ), 446 + ).thenThrow(Exception('network error')); 447 + final cubit = SavedPostsCubit( 448 + database: database, 449 + accountDid: testAccountDid, 450 + postActionRepository: mockRepository, 451 + ); 452 + 453 + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); 454 + 455 + expect(result, isFalse); 456 + expect(cubit.state.error, isNotNull); 457 + final posts = await database.getSavedPosts(testAccountDid); 458 + expect(posts, isEmpty); 459 + }); 460 + }); 461 + 462 + group('cloudUnsave', () { 463 + test('removes cloud-only save from DB', () async { 464 + when(() => mockRepository.deleteBookmark(uri: any(named: 'uri'))).thenAnswer((_) async {}); 465 + final cubit = SavedPostsCubit( 466 + database: database, 467 + accountDid: testAccountDid, 468 + postActionRepository: mockRepository, 469 + ); 470 + await database.savePost( 471 + SavedPostsCompanion( 472 + accountDid: const Value(testAccountDid), 473 + postUri: const Value(testPostUri1), 474 + postJson: const Value(testPostJson1), 475 + saveType: const Value('cloud'), 476 + savedAt: Value(DateTime.now()), 477 + ), 478 + ); 479 + await cubit.loadSavedPosts(); 480 + 481 + final result = await cubit.cloudUnsave(testPostUri1); 482 + 483 + expect(result, isTrue); 484 + final posts = await database.getSavedPosts(testAccountDid); 485 + expect(posts, isEmpty); 486 + }); 487 + 488 + test('downgrades both to local when save type is both', () async { 489 + when(() => mockRepository.deleteBookmark(uri: any(named: 'uri'))).thenAnswer((_) async {}); 490 + final cubit = SavedPostsCubit( 491 + database: database, 492 + accountDid: testAccountDid, 493 + postActionRepository: mockRepository, 494 + ); 495 + await database.savePost( 496 + SavedPostsCompanion( 497 + accountDid: const Value(testAccountDid), 498 + postUri: const Value(testPostUri1), 499 + postJson: const Value(testPostJson1), 500 + saveType: const Value('both'), 501 + savedAt: Value(DateTime.now()), 502 + ), 503 + ); 504 + await cubit.loadSavedPosts(); 505 + 506 + final result = await cubit.cloudUnsave(testPostUri1); 507 + 508 + expect(result, isTrue); 509 + final posts = await database.getSavedPosts(testAccountDid); 510 + expect(posts.first.saveType, equals('local')); 511 + }); 512 + 513 + test('reverts and emits error on API failure for both saveType', () async { 514 + when(() => mockRepository.deleteBookmark(uri: any(named: 'uri'))).thenThrow(Exception('network error')); 515 + final cubit = SavedPostsCubit( 516 + database: database, 517 + accountDid: testAccountDid, 518 + postActionRepository: mockRepository, 519 + ); 520 + await database.savePost( 521 + SavedPostsCompanion( 522 + accountDid: const Value(testAccountDid), 523 + postUri: const Value(testPostUri1), 524 + postJson: const Value(testPostJson1), 525 + saveType: const Value('both'), 526 + savedAt: Value(DateTime.now()), 527 + ), 528 + ); 529 + await cubit.loadSavedPosts(); 530 + 531 + final result = await cubit.cloudUnsave(testPostUri1); 532 + 533 + expect(result, isFalse); 534 + expect(cubit.state.error, isNotNull); 535 + final posts = await database.getSavedPosts(testAccountDid); 536 + expect(posts.first.saveType, equals('both')); 537 + }); 538 + }); 539 + 540 + group('syncCloudBookmarks', () { 541 + test('inserts cloud bookmarks not in DB', () async { 542 + final testUri = AtUri.parse(testPostUri1); 543 + when( 544 + () => mockRepository.getBookmarks( 545 + limit: any(named: 'limit'), 546 + cursor: any(named: 'cursor'), 547 + ), 548 + ).thenAnswer( 549 + (_) async => BookmarkGetBookmarksOutput( 550 + bookmarks: [ 551 + BookmarkView( 552 + subject: RepoStrongRef(uri: testUri, cid: 'cid1'), 553 + item: UBookmarkViewItem.unknown(data: {}), 554 + ), 555 + ], 556 + ), 557 + ); 558 + final cubit = SavedPostsCubit( 559 + database: database, 560 + accountDid: testAccountDid, 561 + postActionRepository: mockRepository, 562 + ); 563 + 564 + await cubit.syncCloudBookmarks(); 565 + 566 + final posts = await database.getSavedPosts(testAccountDid); 567 + expect(posts.length, 1); 568 + expect(posts.first.saveType, equals('cloud')); 569 + expect(posts.first.postUri, equals(testPostUri1)); 570 + }); 571 + 572 + test('upgrades existing local save to both during sync', () async { 573 + await database.savePost( 574 + SavedPostsCompanion( 575 + accountDid: const Value(testAccountDid), 576 + postUri: const Value(testPostUri1), 577 + postJson: const Value(testPostJson1), 578 + saveType: const Value('local'), 579 + savedAt: Value(DateTime.now()), 580 + ), 581 + ); 582 + final testUri = AtUri.parse(testPostUri1); 583 + when( 584 + () => mockRepository.getBookmarks( 585 + limit: any(named: 'limit'), 586 + cursor: any(named: 'cursor'), 587 + ), 588 + ).thenAnswer( 589 + (_) async => BookmarkGetBookmarksOutput( 590 + bookmarks: [ 591 + BookmarkView( 592 + subject: RepoStrongRef(uri: testUri, cid: 'cid1'), 593 + item: UBookmarkViewItem.unknown(data: {}), 594 + ), 595 + ], 596 + ), 597 + ); 598 + final cubit = SavedPostsCubit( 599 + database: database, 600 + accountDid: testAccountDid, 601 + postActionRepository: mockRepository, 602 + ); 603 + 604 + await cubit.syncCloudBookmarks(); 605 + 606 + final posts = await database.getSavedPosts(testAccountDid); 607 + expect(posts.first.saveType, equals('both')); 608 + }); 609 + 610 + test('handles sync API errors gracefully', () async { 611 + when( 612 + () => mockRepository.getBookmarks( 613 + limit: any(named: 'limit'), 614 + cursor: any(named: 'cursor'), 615 + ), 616 + ).thenThrow(Exception('network error')); 617 + final cubit = SavedPostsCubit( 618 + database: database, 619 + accountDid: testAccountDid, 620 + postActionRepository: mockRepository, 621 + ); 622 + 623 + expect(() => cubit.syncCloudBookmarks(), returnsNormally); 293 624 }); 294 625 }); 295 626 });
+59
test/features/feed/data/post_action_repository_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 1 3 import 'package:flutter_test/flutter_test.dart'; 2 4 import 'package:lazurite/features/feed/data/post_action_repository.dart'; 3 5 ··· 5 7 final Map<String, dynamic> _likes = {}; 6 8 final Map<String, dynamic> _reposts = {}; 7 9 final Set<String> _deletedPosts = {}; 10 + final Map<String, String> _bookmarks = {}; 8 11 9 12 @override 10 13 Future<String> likePost({required dynamic uri, required String cid}) async { ··· 35 38 _deletedPosts.add(postUri); 36 39 } 37 40 41 + @override 42 + Future<void> createBookmark({required AtUri uri, required String cid}) async { 43 + _bookmarks[uri.toString()] = cid; 44 + } 45 + 46 + @override 47 + Future<void> deleteBookmark({required AtUri uri}) async { 48 + _bookmarks.remove(uri.toString()); 49 + } 50 + 51 + @override 52 + Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 53 + return const BookmarkGetBookmarksOutput(bookmarks: []); 54 + } 55 + 38 56 bool isLiked(String postUri) => _likes.containsKey(postUri); 39 57 bool isReposted(String postUri) => _reposts.containsKey(postUri); 40 58 bool isDeleted(String postUri) => _deletedPosts.contains(postUri); 59 + bool isBookmarked(String postUri) => _bookmarks.containsKey(postUri); 41 60 String? getLikeUri(String postUri) => _likes[postUri]; 42 61 String? getRepostUri(String postUri) => _reposts[postUri]; 43 62 } ··· 148 167 await repository.repostPost(uri: uri, cid: testCid); 149 168 expect(repository.isLiked(uri.toString()), isTrue); 150 169 expect(repository.isReposted(uri.toString()), isTrue); 170 + }); 171 + }); 172 + 173 + group('createBookmark', () { 174 + test('should add bookmark', () async { 175 + final uri = AtUri.parse('at://did:plc:test/app.bsky.feed.post/abc123'); 176 + 177 + expect(repository.isBookmarked(uri.toString()), isFalse); 178 + 179 + await repository.createBookmark(uri: uri, cid: testCid); 180 + 181 + expect(repository.isBookmarked(uri.toString()), isTrue); 182 + }); 183 + }); 184 + 185 + group('deleteBookmark', () { 186 + test('should remove bookmark', () async { 187 + final uri = AtUri.parse('at://did:plc:test/app.bsky.feed.post/abc123'); 188 + await repository.createBookmark(uri: uri, cid: testCid); 189 + 190 + expect(repository.isBookmarked(uri.toString()), isTrue); 191 + 192 + await repository.deleteBookmark(uri: uri); 193 + 194 + expect(repository.isBookmarked(uri.toString()), isFalse); 195 + }); 196 + }); 197 + 198 + group('getBookmarks', () { 199 + test('should return empty bookmarks list', () async { 200 + final output = await repository.getBookmarks(); 201 + 202 + expect(output.bookmarks, isEmpty); 203 + expect(output.cursor, isNull); 204 + }); 205 + 206 + test('should accept limit and cursor params', () async { 207 + final output = await repository.getBookmarks(limit: 10, cursor: 'abc'); 208 + 209 + expect(output.bookmarks, isEmpty); 151 210 }); 152 211 }); 153 212 });
+46 -5
test/features/feed/presentation/post_action_bar_test.dart
··· 10 10 bool isLiked = false, 11 11 bool isReposted = false, 12 12 bool isSaved = false, 13 + String? saveType, 13 14 VoidCallback? onSave, 14 15 VoidCallback? onLongPressSave, 16 + VoidCallback? onCloudSave, 17 + VoidCallback? onCloudUnsave, 15 18 VoidCallback? onRepost, 16 19 VoidCallback? onLike, 17 20 VoidCallback? onReply, ··· 26 29 isLiked: isLiked, 27 30 isReposted: isReposted, 28 31 isSaved: isSaved, 32 + saveType: saveType, 29 33 postUri: 'at://did:plc:author/app.bsky.feed.post/abc123', 30 34 onSave: onSave, 31 35 onLongPressSave: onLongPressSave, 36 + onCloudSave: onCloudSave, 37 + onCloudUnsave: onCloudUnsave, 32 38 onRepost: onRepost, 33 39 onLike: onLike, 34 40 onReply: onReply, ··· 50 56 51 57 testWidgets('does not show saveCount when zero', (tester) async { 52 58 await tester.pumpWidget(_buildBar(saveCount: 0)); 53 - 54 - // Count of 0 is not shown — only counts > 0 are rendered 55 59 expect(find.text('0'), findsNothing); 56 60 }); 57 61 ··· 81 85 }); 82 86 83 87 testWidgets('save menu shows Remove label when already saved', (tester) async { 84 - await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); 88 + await tester.pumpWidget(_buildBar(isSaved: true, saveType: 'local', onSave: () {})); 85 89 86 90 await tester.tap(find.byIcon(Icons.bookmark)); 87 91 await tester.pumpAndSettle(); ··· 102 106 expect(saveCalled, isTrue); 103 107 }); 104 108 105 - testWidgets('bookmark icon is amber when saved', (tester) async { 106 - await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); 109 + testWidgets('bookmark icon is amber when saved locally', (tester) async { 110 + await tester.pumpWidget(_buildBar(isSaved: true, saveType: 'local', onSave: () {})); 107 111 108 112 final icon = tester.widget<Icon>(find.byIcon(Icons.bookmark)); 109 113 expect(icon.color, equals(Colors.amber)); 114 + }); 115 + 116 + testWidgets('tapping Save to Bluesky calls onCloudSave', (tester) async { 117 + var cloudSaveCalled = false; 118 + await tester.pumpWidget(_buildBar(onSave: () {}, onCloudSave: () => cloudSaveCalled = true)); 119 + 120 + await tester.tap(find.byIcon(Icons.bookmark_outline)); 121 + await tester.pumpAndSettle(); 122 + 123 + await tester.tap(find.text('Save to Bluesky')); 124 + await tester.pumpAndSettle(); 125 + 126 + expect(cloudSaveCalled, isTrue); 127 + }); 128 + 129 + testWidgets('save menu shows Remove from Bluesky when cloud saved', (tester) async { 130 + await tester.pumpWidget(_buildBar(isSaved: true, saveType: 'cloud', onSave: () {}, onCloudUnsave: () {})); 131 + 132 + await tester.tap(find.byIcon(Icons.bookmark)); 133 + await tester.pumpAndSettle(); 134 + 135 + expect(find.text('Remove from Bluesky'), findsOneWidget); 136 + }); 137 + 138 + testWidgets('tapping Remove from Bluesky calls onCloudUnsave', (tester) async { 139 + var cloudUnsaveCalled = false; 140 + await tester.pumpWidget( 141 + _buildBar(isSaved: true, saveType: 'cloud', onSave: () {}, onCloudUnsave: () => cloudUnsaveCalled = true), 142 + ); 143 + 144 + await tester.tap(find.byIcon(Icons.bookmark)); 145 + await tester.pumpAndSettle(); 146 + 147 + await tester.tap(find.text('Remove from Bluesky')); 148 + await tester.pumpAndSettle(); 149 + 150 + expect(cloudUnsaveCalled, isTrue); 110 151 }); 111 152 }); 112 153 }
-2
test/features/feed/presentation/post_card_test.dart
··· 103 103 104 104 await tester.pumpWidget(buildSubject(post, onTap: () => tapped = true)); 105 105 106 - // Tap the author handle which is in the content InkWell (not the action bar). 107 106 await tester.tap(find.text('test.bsky.social', findRichText: true).first); 108 107 expect(tapped, isTrue); 109 108 }); ··· 111 110 testWidgets('does not call onTap when onTap is null', (tester) async { 112 111 final post = _makePost(); 113 112 await tester.pumpWidget(buildSubject(post)); 114 - // Should not throw when tapping without a callback. 115 113 await tester.tap(find.text('test.bsky.social', findRichText: true).first); 116 114 await tester.pump(); 117 115 });
+7 -2
test/features/feed/presentation/saved_posts_screen_test.dart
··· 7 7 import 'package:flutter/material.dart'; 8 8 import 'package:flutter_bloc/flutter_bloc.dart'; 9 9 import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 10 11 import 'package:lazurite/core/database/app_database.dart'; 11 12 import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 12 13 import 'package:lazurite/features/feed/data/post_action_repository.dart'; ··· 58 59 mockDatabase = MockAppDatabase(); 59 60 mockPostActionRepository = MockPostActionRepository(); 60 61 61 - // Default stubs: empty saved posts 62 62 when(() => mockDatabase.watchSavedPostsWithType(testAccountDid)).thenAnswer((_) => Stream.value({})); 63 63 when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); 64 + when( 65 + () => mockPostActionRepository.getBookmarks( 66 + limit: any(named: 'limit'), 67 + cursor: any(named: 'cursor'), 68 + ), 69 + ).thenAnswer((_) async => const BookmarkGetBookmarksOutput(bookmarks: [])); 64 70 }); 65 71 66 72 Widget buildSubject() { ··· 146 152 147 153 await tester.pumpWidget(buildSubject()); 148 154 149 - // First frame shows loading since getSavedPosts hasn't resolved yet 150 155 expect(find.byType(CircularProgressIndicator), findsOneWidget); 151 156 152 157 completer.complete([]);