diff --git a/docs/BUGS.md b/docs/BUGS.md index 72b0bae..b18a56c 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -15,8 +15,9 @@ updated: 2026-03-17 - [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) - [x] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) - [x] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) -- [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert) -- [ ] [12. Delete Post — Remove from Feed](#12-delete-post--remove-from-feed) +- [ ] [11. Saved Posts — Cloud Save via AT Protocol](#11-saved-posts--cloud-save-via-at-protocol) +- [ ] [12. Failed Action Snackbar with Revert](#12-failed-action-snackbar-with-revert) +- [ ] [13. Delete Post — Remove from Feed](#13-delete-post--remove-from-feed) ## 1. Post Thread Screen @@ -253,7 +254,51 @@ are never fetched or displayed. - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — use the passed count instead of hardcoded `0` -## 11. Failed Action Snackbar with Revert +## 11. Saved Posts — Cloud Save via AT Protocol + +**Status:** Not implemented — "Save to Bluesky" option is disabled with "Coming soon" placeholder. + +**Problem:** The save menu in `PostActionBar._showSaveOptions()` has a disabled "Save to +Bluesky" option. The `bluesky` package already exposes a bookmark API +(`app.bsky.bookmark.*`) but it is not wired up. Currently all saves are local-only. + +**Fix:** + +- Add bookmark methods to `PostActionRepository` using the existing `_bluesky.bookmark` + service: + - `createBookmark({uri, cid})` → `_bluesky.bookmark.createBookmark(uri, cid)` + - `deleteBookmark({uri})` → `_bluesky.bookmark.deleteBookmark(uri)` + - `getBookmarks({limit, cursor})` → `_bluesky.bookmark.getBookmarks(limit, cursor)` +- Add `cloudSave` and `cloudUnsave` methods to `SavedPostsCubit`: + - Call `PostActionRepository.createBookmark` / `deleteBookmark`. + - On success, upsert the local DB row with `saveType: 'cloud'` (or `'both'` if already + saved locally). On cloud unsave, downgrade `saveType` to `'local'` if a local save + exists, or delete the row entirely. + - Use optimistic UI: update the icon immediately, revert on failure. +- Enable the "Save to Bluesky" / "Remove from Bluesky" option in + `PostActionBar._showSaveOptions()` and wire it to `SavedPostsCubit.cloudSave` / + `cloudUnsave` via a new callback. +- Distinguish cloud vs local saves visually: + - Local-only: amber/gold bookmark icon. + - Cloud (or both): primary/blue bookmark icon. + - `PostActionBar` already receives `isSaved`; extend it with a `saveType` parameter + (or similar) so the icon color reflects the save type. +- Add a one-time sync on login: call `getBookmarks` (paginated) and merge results into + the local DB so cloud saves made on other clients appear. Mark these as `saveType: + 'cloud'`. + +**Files:** + +- Edit: `lib/features/feed/data/post_action_repository.dart` — add `createBookmark`, + `deleteBookmark`, `getBookmarks` methods +- Edit: `lib/features/feed/cubit/saved_posts_cubit.dart` — add `cloudSave`, + `cloudUnsave`, `syncCloudBookmarks` methods; handle `saveType` transitions +- Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — enable cloud + save option, accept `saveType` parameter, update icon color logic +- Edit: `lib/features/feed/presentation/widgets/post_card_with_actions.dart` — pass + `saveType` and cloud save/unsave callbacks to `PostActionBar` + +## 12. Failed Action Snackbar with Revert **Status:** Partially implemented — rollback works but snackbar is basic. @@ -278,7 +323,7 @@ a snackbar via `BlocListener` in `post_card_with_actions.dart` (lines 55-64). Ho - Edit: `lib/features/feed/presentation/widgets/post_action_bar.dart` — show loading state visually on like/repost buttons -## 12. Delete Post — Remove from Feed +## 13. Delete Post — Remove from Feed **Status:** Incomplete — post is deleted on the server but remains visible in the feed. diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index ee95716..830f9b4 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -255,6 +255,12 @@ class AppDatabase extends _$AppDatabase { return (delete(savedPosts)..where((s) => s.accountDid.equals(accountDid))).go(); } + Future updateSaveType(String accountDid, String postUri, String saveType) async { + final query = update(savedPosts)..where((s) => s.accountDid.equals(accountDid) & s.postUri.equals(postUri)); + final rowsAffected = await query.write(SavedPostsCompanion(saveType: Value(saveType))); + return rowsAffected > 0; + } + Stream> watchSavedPosts(String accountDid) { return (select(savedPosts) ..where((s) => s.accountDid.equals(accountDid)) diff --git a/lib/features/feed/cubit/saved_posts_cubit.dart b/lib/features/feed/cubit/saved_posts_cubit.dart index ea5fcdb..81a9a39 100644 --- a/lib/features/feed/cubit/saved_posts_cubit.dart +++ b/lib/features/feed/cubit/saved_posts_cubit.dart @@ -1,10 +1,14 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_bookmark_defs.dart'; import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; class SavedPostsState extends Equatable { const SavedPostsState({ @@ -51,15 +55,20 @@ class SavedPostsState extends Equatable { enum SavedPostsStatus { initial, loading, loaded, error } class SavedPostsCubit extends Cubit { - SavedPostsCubit({required AppDatabase database, required String accountDid}) - : _database = database, - _accountDid = accountDid, - super(const SavedPostsState()) { + SavedPostsCubit({ + required AppDatabase database, + required String accountDid, + required PostActionRepository postActionRepository, + }) : _database = database, + _accountDid = accountDid, + _postActionRepository = postActionRepository, + super(const SavedPostsState()) { _init(); } final AppDatabase _database; final String _accountDid; + final PostActionRepository _postActionRepository; StreamSubscription>? _savedUrisSubscription; void _init() { @@ -73,6 +82,7 @@ class SavedPostsCubit extends Cubit { log.e('Error watching saved post URIs', error: error); }, ); + unawaited(syncCloudBookmarks()); } Future loadSavedPosts() async { @@ -81,8 +91,11 @@ class SavedPostsCubit extends Cubit { try { final posts = await _database.getSavedPosts(_accountDid); final uris = posts.map((p) => p.postUri).toSet(); + final typeByUri = {for (final p in posts) p.postUri: p.saveType}; - emit(state.copyWith(status: SavedPostsStatus.loaded, savedPosts: posts, savedUris: uris)); + emit( + state.copyWith(status: SavedPostsStatus.loaded, savedPosts: posts, savedUris: uris, saveTypeByUri: typeByUri), + ); } catch (error) { log.e('Failed to load saved posts', error: error); emit(state.copyWith(status: SavedPostsStatus.error, error: 'Failed to load saved posts')); @@ -154,6 +167,102 @@ class SavedPostsCubit extends Cubit { emit(state.copyWith(error: null)); } + Future cloudSave({required String postUri, required String cid, required String postJson}) async { + final currentType = state.saveTypeForUri(postUri); + if (currentType == 'cloud' || currentType == 'both') return true; + + final isLocalSaved = currentType == 'local'; + try { + if (isLocalSaved) { + await _database.updateSaveType(_accountDid, postUri, 'both'); + } else { + await _database.savePost( + SavedPostsCompanion( + accountDid: Value(_accountDid), + postUri: Value(postUri), + postJson: Value(postJson), + saveType: const Value('cloud'), + savedAt: Value(DateTime.now()), + ), + ); + } + await _postActionRepository.createBookmark(uri: AtUri.parse(postUri), cid: cid); + return true; + } catch (error) { + log.e('Failed to cloud save post', error: error); + if (isLocalSaved) { + await _database.updateSaveType(_accountDid, postUri, 'local'); + } else { + await _database.unsavePost(_accountDid, postUri); + } + emit(state.copyWith(error: 'Failed to save post to Bluesky')); + return false; + } + } + + Future cloudUnsave(String postUri) async { + final currentType = state.saveTypeForUri(postUri); + if (currentType == null || currentType == 'local') return true; + + final existingEntry = await _database.getSavedPost(_accountDid, postUri); + try { + if (currentType == 'both') { + await _database.updateSaveType(_accountDid, postUri, 'local'); + } else { + await _database.unsavePost(_accountDid, postUri); + } + await _postActionRepository.deleteBookmark(uri: AtUri.parse(postUri)); + return true; + } catch (error) { + log.e('Failed to cloud unsave post', error: error); + if (currentType == 'both') { + await _database.updateSaveType(_accountDid, postUri, 'both'); + } else if (existingEntry != null) { + await _database.savePost( + SavedPostsCompanion( + accountDid: Value(_accountDid), + postUri: Value(postUri), + postJson: Value(existingEntry.postJson), + saveType: const Value('cloud'), + savedAt: Value(existingEntry.savedAt), + ), + ); + } + emit(state.copyWith(error: 'Failed to remove post from Bluesky')); + return false; + } + } + + Future syncCloudBookmarks() async { + try { + String? cursor; + do { + final output = await _postActionRepository.getBookmarks(limit: 100, cursor: cursor); + for (final bookmark in output.bookmarks) { + final postUri = bookmark.subject.uri.toString(); + final postJson = bookmark.item.isPostView ? jsonEncode(bookmark.item.postView!.toJson()) : '{}'; + final existing = await _database.getSavedPost(_accountDid, postUri); + if (existing == null) { + await _database.savePost( + SavedPostsCompanion( + accountDid: Value(_accountDid), + postUri: Value(postUri), + postJson: Value(postJson), + saveType: const Value('cloud'), + savedAt: Value(bookmark.createdAt ?? DateTime.now()), + ), + ); + } else if (existing.saveType == 'local') { + await _database.updateSaveType(_accountDid, postUri, 'both'); + } + } + cursor = output.cursor; + } while (cursor != null); + } catch (error) { + log.e('Failed to sync cloud bookmarks', error: error); + } + } + @override Future close() { _savedUrisSubscription?.cancel(); diff --git a/lib/features/feed/data/post_action_repository.dart b/lib/features/feed/data/post_action_repository.dart index 9181d70..108e0ac 100644 --- a/lib/features/feed/data/post_action_repository.dart +++ b/lib/features/feed/data/post_action_repository.dart @@ -1,5 +1,6 @@ import 'package:atproto/com_atproto_repo_strongref.dart'; import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; import 'package:bluesky/bluesky.dart'; class PostActionRepository { @@ -40,6 +41,19 @@ class PostActionRepository { await _bluesky.feed.post.delete(rkey: rkey); } + Future createBookmark({required AtUri uri, required String cid}) async { + await _bluesky.bookmark.createBookmark(uri: uri, cid: cid); + } + + Future deleteBookmark({required AtUri uri}) async { + await _bluesky.bookmark.deleteBookmark(uri: uri); + } + + Future getBookmarks({int? limit, String? cursor}) async { + final response = await _bluesky.bookmark.getBookmarks(limit: limit, cursor: cursor); + return response.data; + } + String _extractRkey(String uri) { final atUri = AtUri.parse(uri); return atUri.rkey; diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart index 8b7950c..d4675bb 100644 --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:share_plus/share_plus.dart'; @@ -18,8 +19,11 @@ class SavedPostsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => - SavedPostsCubit(database: context.read(), accountDid: accountDid)..loadSavedPosts(), + create: (context) => SavedPostsCubit( + database: context.read(), + accountDid: accountDid, + postActionRepository: context.read(), + )..loadSavedPosts(), child: const _SavedPostsContent(), ); } diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart index 69f2641..16e7721 100644 --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -13,6 +13,7 @@ class PostActionBar extends StatelessWidget { required this.isLiked, required this.isReposted, required this.isSaved, + this.saveType, required this.postUri, this.postCid, this.onReply, @@ -22,6 +23,8 @@ class PostActionBar extends StatelessWidget { this.onShare, this.onSave, this.onLongPressSave, + this.onCloudSave, + this.onCloudUnsave, this.onMore, this.isLoadingLike = false, this.isLoadingRepost = false, @@ -34,6 +37,7 @@ class PostActionBar extends StatelessWidget { final bool isLiked; final bool isReposted; final bool isSaved; + final String? saveType; final String postUri; final String? postCid; final VoidCallback? onReply; @@ -43,6 +47,8 @@ class PostActionBar extends StatelessWidget { final VoidCallback? onShare; final VoidCallback? onSave; final VoidCallback? onLongPressSave; + final VoidCallback? onCloudSave; + final VoidCallback? onCloudUnsave; final VoidCallback? onMore; final bool isLoadingLike; final bool isLoadingRepost; @@ -86,7 +92,9 @@ class PostActionBar extends StatelessWidget { onTap: onSave != null ? () => _showSaveOptions(context) : null, onLongPress: onLongPressSave, color: Theme.of(context).colorScheme.onSurfaceVariant, - activeColor: Colors.amber, + activeColor: (saveType == 'cloud' || saveType == 'both') + ? Theme.of(context).colorScheme.primary + : Colors.amber, ), _ActionButton( icon: Icons.share_outlined, @@ -142,6 +150,8 @@ class PostActionBar extends StatelessWidget { void _showSaveOptions(BuildContext context) { HapticFeedback.mediumImpact(); + final isLocalSaved = isSaved && (saveType == 'local' || saveType == 'both'); + final isCloudSaved = saveType == 'cloud' || saveType == 'both'; showModalBottomSheet( context: context, builder: (context) => SafeArea( @@ -150,23 +160,29 @@ class PostActionBar extends StatelessWidget { children: [ ListTile( leading: Icon( - isSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, + isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, color: Colors.amber, ), - title: Text(isSaved ? 'Remove local save' : 'Save locally'), + title: Text(isLocalSaved ? 'Remove local save' : 'Save locally'), onTap: () { Navigator.pop(context); onSave?.call(); }, ), ListTile( - enabled: false, leading: Icon( - Icons.cloud_outlined, - color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, + color: Theme.of(context).colorScheme.primary, ), - title: const Text('Save to Bluesky'), - subtitle: const Text('Coming soon'), + title: Text(isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky'), + onTap: () { + Navigator.pop(context); + if (isCloudSaved) { + onCloudUnsave?.call(); + } else { + onCloudSave?.call(); + } + }, ), ], ), diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index 9772bc4..3351150 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -87,6 +87,7 @@ class _PostCardWithActionsContent extends StatelessWidget { isLiked: postActionState.isLiked, isReposted: postActionState.isReposted, isSaved: savedState.isSaved(post.uri.toString()), + saveType: savedState.saveTypeForUri(post.uri.toString()), postUri: post.uri.toString(), postCid: post.cid, isLoadingLike: postActionState.isLoadingLike, @@ -101,6 +102,12 @@ class _PostCardWithActionsContent extends StatelessWidget { onLongPressSave: () { unawaited(_onToggleSave(context)); }, + onCloudSave: () { + unawaited(_onCloudSave(context)); + }, + onCloudUnsave: () { + unawaited(_onCloudUnsave(context)); + }, onMore: () => _showMoreOptions(context), ); }, @@ -155,6 +162,22 @@ class _PostCardWithActionsContent extends StatelessWidget { await cubit.toggleSave(postUri: post.uri.toString(), postJson: jsonEncode(post.toJson())); } + Future _onCloudSave(BuildContext context) async { + final cubit = context.read(); + final post = feedViewPost.post; + + await HapticFeedback.lightImpact(); + await cubit.cloudSave(postUri: post.uri.toString(), cid: post.cid, postJson: jsonEncode(post.toJson())); + } + + Future _onCloudUnsave(BuildContext context) async { + final cubit = context.read(); + final post = feedViewPost.post; + + await HapticFeedback.lightImpact(); + await cubit.cloudUnsave(post.uri.toString()); + } + void _showMoreOptions(BuildContext context) { HapticFeedback.mediumImpact(); final post = feedViewPost.post; diff --git a/lib/main.dart b/lib/main.dart index 6550d55..6f30819 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -148,7 +148,11 @@ class _LazuriteAppState extends State { SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), ), BlocProvider( - create: (_) => SavedPostsCubit(database: widget.database, accountDid: accountDid), + create: (_) => SavedPostsCubit( + database: widget.database, + accountDid: accountDid, + postActionRepository: postActionRepository, + ), ), RepositoryProvider.value(value: feedRepository), RepositoryProvider.value(value: searchRepository), diff --git a/test/features/feed/cubit/post_action_cubit_test.dart b/test/features/feed/cubit/post_action_cubit_test.dart index 2b53b0b..ed9ef51 100644 --- a/test/features/feed/cubit/post_action_cubit_test.dart +++ b/test/features/feed/cubit/post_action_cubit_test.dart @@ -287,7 +287,6 @@ void main() { postActionRepository: mockRepository, postUri: testPostUri, postCid: testPostCid, - // API says unliked with 0 counts — cache should win isLiked: false, likeCount: 0, cache: cache, @@ -340,9 +339,6 @@ void main() { await cubit.toggleLike(); }, verify: (cubit) { - // After settling, cache should reflect liked state. - // We verify by re-seeding a new cubit from the same cache. - // The cubit exposes its cache indirectly — just check final state. expect(cubit.state.isLiked, isTrue); expect(cubit.state.likeUri, testLikeUri); expect(cubit.state.isLoadingLike, isFalse); @@ -396,13 +392,12 @@ void main() { await cubit1.toggleLike(); await cubit1.close(); - // Simulate widget recycling: new cubit with stale API data (isLiked=false). final cubit2 = PostActionCubit( postActionRepository: mockRepository, postUri: testPostUri, postCid: testPostCid, isLiked: false, - likeCount: 3, // stale API count + likeCount: 3, cache: cache, ); diff --git a/test/features/feed/cubit/saved_posts_cubit_test.dart b/test/features/feed/cubit/saved_posts_cubit_test.dart index 400642f..b6cc4f3 100644 --- a/test/features/feed/cubit/saved_posts_cubit_test.dart +++ b/test/features/feed/cubit/saved_posts_cubit_test.dart @@ -1,11 +1,24 @@ -import 'package:drift/drift.dart' hide isNull; +import 'package:atproto/com_atproto_repo_strongref.dart'; +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_bookmark_defs.dart'; +import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; +import 'package:drift/drift.dart' hide isNull, isNotNull; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; +import 'package:lazurite/features/feed/data/post_action_repository.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockPostActionRepository extends Mock implements PostActionRepository {} void main() { + setUpAll(() { + registerFallbackValue(AtUri.parse('at://did:plc:test/app.bsky.feed.post/fallback')); + }); + late AppDatabase database; + late MockPostActionRepository mockRepository; const testAccountDid = 'did:plc:testuser123'; const testPostUri1 = 'at://did:plc:author1/app.bsky.feed.post/abc123'; @@ -15,6 +28,13 @@ void main() { setUp(() async { database = AppDatabase(executor: NativeDatabase.memory()); + mockRepository = MockPostActionRepository(); + when( + () => mockRepository.getBookmarks( + limit: any(named: 'limit'), + cursor: any(named: 'cursor'), + ), + ).thenAnswer((_) async => const BookmarkGetBookmarksOutput(bookmarks: [])); }); tearDown(() async { @@ -23,7 +43,11 @@ void main() { group('SavedPostsCubit', () { test('initial state has correct values', () { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); expect(cubit.state.status, SavedPostsStatus.initial); expect(cubit.state.savedPosts, isEmpty); @@ -33,7 +57,11 @@ void main() { group('loadSavedPosts', () { test('loads empty list when no saved posts', () async { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); @@ -51,7 +79,11 @@ void main() { ), ); - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); @@ -63,7 +95,11 @@ void main() { group('toggleSave', () { test('saves post when not saved', () async { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); @@ -81,7 +117,11 @@ void main() { ), ); - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); expect(cubit.state.savedPosts.length, 1); @@ -95,7 +135,11 @@ void main() { group('savePost', () { test('saves post when not already saved', () async { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); final result = await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); @@ -113,7 +157,11 @@ void main() { ), ); - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); final result = await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); @@ -133,7 +181,11 @@ void main() { ), ); - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); expect(cubit.state.savedPosts.length, 1); @@ -155,7 +207,11 @@ void main() { ), ); - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); final posts = await database.getSavedPosts(testAccountDid); @@ -185,7 +241,11 @@ void main() { ), ); - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.loadSavedPosts(); expect(cubit.state.savedPosts.length, 2); @@ -199,7 +259,11 @@ void main() { group('isSaved', () { test('returns true when post is saved', () async { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.savePost(postUri: testPostUri1, postJson: testPostJson1); @@ -273,7 +337,11 @@ void main() { group('saveType', () { test('toggleSave saves post with local saveType', () async { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); @@ -283,7 +351,11 @@ void main() { }); test('saveTypeForUri returns local after saving', () async { - final cubit = SavedPostsCubit(database: database, accountDid: testAccountDid); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); await cubit.loadSavedPosts(); @@ -292,5 +364,264 @@ void main() { expect(cubit.state.saveTypeForUri(testPostUri2), isNull); }); }); + + group('cloudSave', () { + test('inserts post with cloud saveType when not saved', () async { + when( + () => mockRepository.createBookmark( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ).thenAnswer((_) async {}); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + + expect(result, isTrue); + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.length, 1); + expect(posts.first.saveType, equals('cloud')); + }); + + test('upgrades local save to both when already locally saved', () async { + when( + () => mockRepository.createBookmark( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ).thenAnswer((_) async {}); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + await cubit.toggleSave(postUri: testPostUri1, postJson: testPostJson1); + await cubit.loadSavedPosts(); + + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + + expect(result, isTrue); + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.first.saveType, equals('both')); + }); + + test('returns true without API call when already cloud saved', () async { + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + await database.savePost( + SavedPostsCompanion( + accountDid: const Value(testAccountDid), + postUri: const Value(testPostUri1), + postJson: const Value(testPostJson1), + saveType: const Value('cloud'), + savedAt: Value(DateTime.now()), + ), + ); + await cubit.loadSavedPosts(); + + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + + expect(result, isTrue); + verifyNever( + () => mockRepository.createBookmark( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ); + }); + + test('reverts and emits error on API failure', () async { + when( + () => mockRepository.createBookmark( + uri: any(named: 'uri'), + cid: any(named: 'cid'), + ), + ).thenThrow(Exception('network error')); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + + final result = await cubit.cloudSave(postUri: testPostUri1, cid: 'cid1', postJson: testPostJson1); + + expect(result, isFalse); + expect(cubit.state.error, isNotNull); + final posts = await database.getSavedPosts(testAccountDid); + expect(posts, isEmpty); + }); + }); + + group('cloudUnsave', () { + test('removes cloud-only save from DB', () async { + when(() => mockRepository.deleteBookmark(uri: any(named: 'uri'))).thenAnswer((_) async {}); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + await database.savePost( + SavedPostsCompanion( + accountDid: const Value(testAccountDid), + postUri: const Value(testPostUri1), + postJson: const Value(testPostJson1), + saveType: const Value('cloud'), + savedAt: Value(DateTime.now()), + ), + ); + await cubit.loadSavedPosts(); + + final result = await cubit.cloudUnsave(testPostUri1); + + expect(result, isTrue); + final posts = await database.getSavedPosts(testAccountDid); + expect(posts, isEmpty); + }); + + test('downgrades both to local when save type is both', () async { + when(() => mockRepository.deleteBookmark(uri: any(named: 'uri'))).thenAnswer((_) async {}); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + await database.savePost( + SavedPostsCompanion( + accountDid: const Value(testAccountDid), + postUri: const Value(testPostUri1), + postJson: const Value(testPostJson1), + saveType: const Value('both'), + savedAt: Value(DateTime.now()), + ), + ); + await cubit.loadSavedPosts(); + + final result = await cubit.cloudUnsave(testPostUri1); + + expect(result, isTrue); + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.first.saveType, equals('local')); + }); + + test('reverts and emits error on API failure for both saveType', () async { + when(() => mockRepository.deleteBookmark(uri: any(named: 'uri'))).thenThrow(Exception('network error')); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + await database.savePost( + SavedPostsCompanion( + accountDid: const Value(testAccountDid), + postUri: const Value(testPostUri1), + postJson: const Value(testPostJson1), + saveType: const Value('both'), + savedAt: Value(DateTime.now()), + ), + ); + await cubit.loadSavedPosts(); + + final result = await cubit.cloudUnsave(testPostUri1); + + expect(result, isFalse); + expect(cubit.state.error, isNotNull); + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.first.saveType, equals('both')); + }); + }); + + group('syncCloudBookmarks', () { + test('inserts cloud bookmarks not in DB', () async { + final testUri = AtUri.parse(testPostUri1); + when( + () => mockRepository.getBookmarks( + limit: any(named: 'limit'), + cursor: any(named: 'cursor'), + ), + ).thenAnswer( + (_) async => BookmarkGetBookmarksOutput( + bookmarks: [ + BookmarkView( + subject: RepoStrongRef(uri: testUri, cid: 'cid1'), + item: UBookmarkViewItem.unknown(data: {}), + ), + ], + ), + ); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + + await cubit.syncCloudBookmarks(); + + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.length, 1); + expect(posts.first.saveType, equals('cloud')); + expect(posts.first.postUri, equals(testPostUri1)); + }); + + test('upgrades existing local save to both during sync', () async { + await database.savePost( + SavedPostsCompanion( + accountDid: const Value(testAccountDid), + postUri: const Value(testPostUri1), + postJson: const Value(testPostJson1), + saveType: const Value('local'), + savedAt: Value(DateTime.now()), + ), + ); + final testUri = AtUri.parse(testPostUri1); + when( + () => mockRepository.getBookmarks( + limit: any(named: 'limit'), + cursor: any(named: 'cursor'), + ), + ).thenAnswer( + (_) async => BookmarkGetBookmarksOutput( + bookmarks: [ + BookmarkView( + subject: RepoStrongRef(uri: testUri, cid: 'cid1'), + item: UBookmarkViewItem.unknown(data: {}), + ), + ], + ), + ); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + + await cubit.syncCloudBookmarks(); + + final posts = await database.getSavedPosts(testAccountDid); + expect(posts.first.saveType, equals('both')); + }); + + test('handles sync API errors gracefully', () async { + when( + () => mockRepository.getBookmarks( + limit: any(named: 'limit'), + cursor: any(named: 'cursor'), + ), + ).thenThrow(Exception('network error')); + final cubit = SavedPostsCubit( + database: database, + accountDid: testAccountDid, + postActionRepository: mockRepository, + ); + + expect(() => cubit.syncCloudBookmarks(), returnsNormally); + }); + }); }); } diff --git a/test/features/feed/data/post_action_repository_test.dart b/test/features/feed/data/post_action_repository_test.dart index 0f7cbab..19c32ea 100644 --- a/test/features/feed/data/post_action_repository_test.dart +++ b/test/features/feed/data/post_action_repository_test.dart @@ -1,3 +1,5 @@ +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -5,6 +7,7 @@ class MockPostActionRepository implements PostActionRepository { final Map _likes = {}; final Map _reposts = {}; final Set _deletedPosts = {}; + final Map _bookmarks = {}; @override Future likePost({required dynamic uri, required String cid}) async { @@ -35,9 +38,25 @@ class MockPostActionRepository implements PostActionRepository { _deletedPosts.add(postUri); } + @override + Future createBookmark({required AtUri uri, required String cid}) async { + _bookmarks[uri.toString()] = cid; + } + + @override + Future deleteBookmark({required AtUri uri}) async { + _bookmarks.remove(uri.toString()); + } + + @override + Future getBookmarks({int? limit, String? cursor}) async { + return const BookmarkGetBookmarksOutput(bookmarks: []); + } + bool isLiked(String postUri) => _likes.containsKey(postUri); bool isReposted(String postUri) => _reposts.containsKey(postUri); bool isDeleted(String postUri) => _deletedPosts.contains(postUri); + bool isBookmarked(String postUri) => _bookmarks.containsKey(postUri); String? getLikeUri(String postUri) => _likes[postUri]; String? getRepostUri(String postUri) => _reposts[postUri]; } @@ -150,6 +169,46 @@ void main() { expect(repository.isReposted(uri.toString()), isTrue); }); }); + + group('createBookmark', () { + test('should add bookmark', () async { + final uri = AtUri.parse('at://did:plc:test/app.bsky.feed.post/abc123'); + + expect(repository.isBookmarked(uri.toString()), isFalse); + + await repository.createBookmark(uri: uri, cid: testCid); + + expect(repository.isBookmarked(uri.toString()), isTrue); + }); + }); + + group('deleteBookmark', () { + test('should remove bookmark', () async { + final uri = AtUri.parse('at://did:plc:test/app.bsky.feed.post/abc123'); + await repository.createBookmark(uri: uri, cid: testCid); + + expect(repository.isBookmarked(uri.toString()), isTrue); + + await repository.deleteBookmark(uri: uri); + + expect(repository.isBookmarked(uri.toString()), isFalse); + }); + }); + + group('getBookmarks', () { + test('should return empty bookmarks list', () async { + final output = await repository.getBookmarks(); + + expect(output.bookmarks, isEmpty); + expect(output.cursor, isNull); + }); + + test('should accept limit and cursor params', () async { + final output = await repository.getBookmarks(limit: 10, cursor: 'abc'); + + expect(output.bookmarks, isEmpty); + }); + }); }); } diff --git a/test/features/feed/presentation/post_action_bar_test.dart b/test/features/feed/presentation/post_action_bar_test.dart index 39f2143..b19e5b9 100644 --- a/test/features/feed/presentation/post_action_bar_test.dart +++ b/test/features/feed/presentation/post_action_bar_test.dart @@ -10,8 +10,11 @@ Widget _buildBar({ bool isLiked = false, bool isReposted = false, bool isSaved = false, + String? saveType, VoidCallback? onSave, VoidCallback? onLongPressSave, + VoidCallback? onCloudSave, + VoidCallback? onCloudUnsave, VoidCallback? onRepost, VoidCallback? onLike, VoidCallback? onReply, @@ -26,9 +29,12 @@ Widget _buildBar({ isLiked: isLiked, isReposted: isReposted, isSaved: isSaved, + saveType: saveType, postUri: 'at://did:plc:author/app.bsky.feed.post/abc123', onSave: onSave, onLongPressSave: onLongPressSave, + onCloudSave: onCloudSave, + onCloudUnsave: onCloudUnsave, onRepost: onRepost, onLike: onLike, onReply: onReply, @@ -50,8 +56,6 @@ void main() { testWidgets('does not show saveCount when zero', (tester) async { await tester.pumpWidget(_buildBar(saveCount: 0)); - - // Count of 0 is not shown — only counts > 0 are rendered expect(find.text('0'), findsNothing); }); @@ -81,7 +85,7 @@ void main() { }); testWidgets('save menu shows Remove label when already saved', (tester) async { - await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); + await tester.pumpWidget(_buildBar(isSaved: true, saveType: 'local', onSave: () {})); await tester.tap(find.byIcon(Icons.bookmark)); await tester.pumpAndSettle(); @@ -102,11 +106,48 @@ void main() { expect(saveCalled, isTrue); }); - testWidgets('bookmark icon is amber when saved', (tester) async { - await tester.pumpWidget(_buildBar(isSaved: true, onSave: () {})); + testWidgets('bookmark icon is amber when saved locally', (tester) async { + await tester.pumpWidget(_buildBar(isSaved: true, saveType: 'local', onSave: () {})); final icon = tester.widget(find.byIcon(Icons.bookmark)); expect(icon.color, equals(Colors.amber)); }); + + testWidgets('tapping Save to Bluesky calls onCloudSave', (tester) async { + var cloudSaveCalled = false; + await tester.pumpWidget(_buildBar(onSave: () {}, onCloudSave: () => cloudSaveCalled = true)); + + await tester.tap(find.byIcon(Icons.bookmark_outline)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Save to Bluesky')); + await tester.pumpAndSettle(); + + expect(cloudSaveCalled, isTrue); + }); + + testWidgets('save menu shows Remove from Bluesky when cloud saved', (tester) async { + await tester.pumpWidget(_buildBar(isSaved: true, saveType: 'cloud', onSave: () {}, onCloudUnsave: () {})); + + await tester.tap(find.byIcon(Icons.bookmark)); + await tester.pumpAndSettle(); + + expect(find.text('Remove from Bluesky'), findsOneWidget); + }); + + testWidgets('tapping Remove from Bluesky calls onCloudUnsave', (tester) async { + var cloudUnsaveCalled = false; + await tester.pumpWidget( + _buildBar(isSaved: true, saveType: 'cloud', onSave: () {}, onCloudUnsave: () => cloudUnsaveCalled = true), + ); + + await tester.tap(find.byIcon(Icons.bookmark)); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Remove from Bluesky')); + await tester.pumpAndSettle(); + + expect(cloudUnsaveCalled, isTrue); + }); }); } diff --git a/test/features/feed/presentation/post_card_test.dart b/test/features/feed/presentation/post_card_test.dart index 4f2e46c..5a348bc 100644 --- a/test/features/feed/presentation/post_card_test.dart +++ b/test/features/feed/presentation/post_card_test.dart @@ -103,7 +103,6 @@ void main() { await tester.pumpWidget(buildSubject(post, onTap: () => tapped = true)); - // Tap the author handle which is in the content InkWell (not the action bar). await tester.tap(find.text('test.bsky.social', findRichText: true).first); expect(tapped, isTrue); }); @@ -111,7 +110,6 @@ void main() { testWidgets('does not call onTap when onTap is null', (tester) async { final post = _makePost(); await tester.pumpWidget(buildSubject(post)); - // Should not throw when tapping without a callback. await tester.tap(find.text('test.bsky.social', findRichText: true).first); await tester.pump(); }); diff --git a/test/features/feed/presentation/saved_posts_screen_test.dart b/test/features/feed/presentation/saved_posts_screen_test.dart index 93ff948..2dfd8b3 100644 --- a/test/features/feed/presentation/saved_posts_screen_test.dart +++ b/test/features/feed/presentation/saved_posts_screen_test.dart @@ -7,6 +7,7 @@ import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; import 'package:lazurite/core/database/app_database.dart'; import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -58,9 +59,14 @@ void main() { mockDatabase = MockAppDatabase(); mockPostActionRepository = MockPostActionRepository(); - // Default stubs: empty saved posts when(() => mockDatabase.watchSavedPostsWithType(testAccountDid)).thenAnswer((_) => Stream.value({})); when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); + when( + () => mockPostActionRepository.getBookmarks( + limit: any(named: 'limit'), + cursor: any(named: 'cursor'), + ), + ).thenAnswer((_) async => const BookmarkGetBookmarksOutput(bookmarks: [])); }); Widget buildSubject() { @@ -146,7 +152,6 @@ void main() { await tester.pumpWidget(buildSubject()); - // First frame shows loading since getSavedPosts hasn't resolved yet expect(find.byType(CircularProgressIndicator), findsOneWidget); completer.complete([]);