From 1218b10aa70d3e25fa614464702ad5ff8a0db4ea Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Wed, 18 Mar 2026 03:35:10 +0000 Subject: [PATCH] feat: Add 'Saved Posts' button to own profile and update saved posts screen --- docs/BUGS.md | 4 ++-- lib/features/feed/presentation/saved_posts_screen.dart | 96 ++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------- lib/features/profile/presentation/profile_screen.dart | 11 ++++++++++- test/features/feed/presentation/saved_posts_screen_test.dart | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/features/profile/presentation/profile_screen_test.dart | 41 +++++++++++++++++++++++++++++++++++++++++ 5 file(s) changed, 249 insertion(s)(+), 55 deletion(s)(-) diff --git a/docs/BUGS.md b/docs/BUGS.md --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -11,8 +11,8 @@ - [x] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) - [x] [5. Notification Tap Navigation](#5-notification-tap-navigation) - [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) -- [ ] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) -- [ ] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) +- [x] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) +- [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) - [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) - [ ] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) - [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert) diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -1,9 +1,13 @@ +import 'dart:convert'; + +import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; 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/presentation/widgets/post_card_with_actions.dart'; import 'package:share_plus/share_plus.dart'; class SavedPostsScreen extends StatelessWidget { @@ -140,8 +144,21 @@ final SavedPostEntry savedPost; final VoidCallback onUnsave; + FeedViewPost? _deserializePost() { + try { + final json = jsonDecode(savedPost.postJson) as Map; + return FeedViewPost(post: PostView.fromJson(json)); + } catch (e) { + log.e('Failed to deserialize saved post', error: e); + return null; + } + } + @override Widget build(BuildContext context) { + final feedViewPost = _deserializePost(); + final accountDid = context.read(); + return Dismissible( key: ValueKey(savedPost.id), direction: DismissDirection.endToStart, @@ -149,63 +166,38 @@ alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 16), color: Theme.of(context).colorScheme.error, - child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onError), + child: Icon(Icons.bookmark_remove, color: Theme.of(context).colorScheme.onError), ), onDismissed: (_) => onUnsave(), - child: Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: ListTile( - leading: const Icon(Icons.bookmark), - title: const Text('Saved Post'), - subtitle: Text('Saved on ${_formatDate(savedPost.savedAt)}', style: Theme.of(context).textTheme.bodySmall), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.open_in_new), - onPressed: () => _openPost(context), - tooltip: 'Open post', - ), - IconButton( - icon: const Icon(Icons.share_outlined), - onPressed: () => _sharePost(context), - tooltip: 'Share', - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () => _confirmUnsave(context), - tooltip: 'Remove', - ), - ], - ), - ), - ), + child: feedViewPost != null + ? PostCardWithActions(feedViewPost: feedViewPost, accountDid: accountDid) + : _buildFallback(context), ); } - void _openPost(BuildContext context) => context.push('/post/${Uri.encodeComponent(savedPost.postUri)}'); - - void _sharePost(BuildContext context) { - final bskyUrl = _convertAtUriToBskyUrl(savedPost.postUri); - Share.share(bskyUrl); - } - - void _confirmUnsave(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Remove Saved Post?'), - content: const Text('This will remove the post from your saved list.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton( - onPressed: () { - Navigator.pop(context); - onUnsave(); - }, - child: const Text('Remove'), - ), - ], + Widget _buildFallback(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: ListTile( + leading: const Icon(Icons.bookmark), + title: const Text('Saved Post'), + subtitle: Text('Saved on ${_formatDate(savedPost.savedAt)}', style: Theme.of(context).textTheme.bodySmall), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.open_in_new), + onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(savedPost.postUri)}'), + tooltip: 'Open post', + ), + IconButton( + icon: const Icon(Icons.share_outlined), + onPressed: () => Share.share(_convertAtUriToBskyUrl(savedPost.postUri)), + tooltip: 'Share', + ), + IconButton(icon: const Icon(Icons.delete_outline), onPressed: onUnsave, tooltip: 'Remove'), + ], + ), ), ); } diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -91,7 +91,8 @@ return BlocBuilder( builder: (context, feedState) { final profile = profileState.profile; - final isOwnProfile = profile?.did == _resolvedActor; + final currentUserDid = context.read().state.tokens?.did; + final isOwnProfile = profile?.did == currentUserDid; return NestedScrollView( headerSliverBuilder: (context, innerBoxIsScrolled) { @@ -239,6 +240,14 @@ _buildStat(context, profile.postsCount ?? 0, 'Posts'), ], ), + if (isOwnProfile) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => context.push('/saved'), + icon: const Icon(Icons.bookmark_outline), + label: const Text('Saved Posts'), + ), + ], if (!isOwnProfile) ...[const SizedBox(height: 16), _buildProfileActions(context, profile)], ], ), diff --git a/test/features/feed/presentation/saved_posts_screen_test.dart b/test/features/feed/presentation/saved_posts_screen_test.dart new file mode 100644 --- /dev/null +++ b/test/features/feed/presentation/saved_posts_screen_test.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:atproto_core/atproto_core.dart'; +import 'package:bluesky/app_bsky_actor_defs.dart'; +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: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'; +import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockAppDatabase extends Mock implements AppDatabase {} + +class MockPostActionRepository extends Mock implements PostActionRepository {} + +PostView _makePostView({ + String did = 'did:plc:author', + String handle = 'author.bsky.social', + String rkey = 'abc123', + String text = 'Hello world', +}) { + return PostView( + uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), + cid: 'cid-$rkey', + author: ProfileViewBasic(did: did, handle: handle), + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String()}, + indexedAt: DateTime.utc(2026, 3, 15), + ); +} + +SavedPostEntry _makeEntry({ + int id = 1, + String postUri = 'at://did:plc:author/app.bsky.feed.post/abc123', + required String postJson, +}) { + return SavedPostEntry( + id: id, + accountDid: 'did:plc:me', + postUri: postUri, + postJson: postJson, + savedAt: DateTime.utc(2026, 3, 15), + ); +} + +void main() { + late MockAppDatabase mockDatabase; + late MockPostActionRepository mockPostActionRepository; + + const testAccountDid = 'did:plc:me'; + + setUp(() { + mockDatabase = MockAppDatabase(); + mockPostActionRepository = MockPostActionRepository(); + + // Default stubs: empty saved posts + when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({})); + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); + }); + + Widget buildSubject() { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: mockDatabase), + RepositoryProvider.value(value: mockPostActionRepository), + RepositoryProvider(create: (_) => PostActionCache()), + RepositoryProvider.value(value: testAccountDid), + ], + child: const MaterialApp(home: SavedPostsScreen(accountDid: testAccountDid)), + ); + } + + testWidgets('shows empty state when no saved posts', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('No saved posts'), findsOneWidget); + expect(find.text('Posts you save will appear here'), findsOneWidget); + }); + + testWidgets('renders PostCardWithActions for a saved post with valid postJson', (tester) async { + final postView = _makePostView(text: 'Saved post content'); + final postJson = jsonEncode(postView.toJson()); + final entry = _makeEntry(postUri: postView.uri.toString(), postJson: postJson); + + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); + when( + () => mockDatabase.watchSavedPostUris(testAccountDid), + ).thenAnswer((_) => Stream.value({postView.uri.toString()})); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.byType(PostCardWithActions), findsOneWidget); + }); + + testWidgets('renders fallback card when postJson is invalid', (tester) async { + final entry = _makeEntry(postJson: 'not valid json {{{'); + + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); + when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({entry.postUri})); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.text('Saved Post'), findsOneWidget); + expect(find.byType(PostCardWithActions), findsNothing); + }); + + testWidgets('swipe to dismiss calls unsavePostById', (tester) async { + final postView = _makePostView(); + final postJson = jsonEncode(postView.toJson()); + final entry = _makeEntry(postUri: postView.uri.toString(), postJson: postJson); + + var callCount = 0; + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) { + callCount++; + return Future.value(callCount == 1 ? [entry] : []); + }); + when( + () => mockDatabase.watchSavedPostUris(testAccountDid), + ).thenAnswer((_) => Stream.value({postView.uri.toString()})); + when(() => mockDatabase.unsavePostById(entry.id)).thenAnswer((_) => Future.value(1)); + + await tester.pumpWidget(buildSubject()); + await tester.pump(); + + expect(find.byType(Dismissible), findsOneWidget); + + await tester.drag(find.byType(Dismissible), const Offset(-500, 0)); + await tester.pumpAndSettle(); + + verify(() => mockDatabase.unsavePostById(entry.id)).called(1); + }); + + testWidgets('shows loading indicator while loading', (tester) async { + final completer = Completer>(); + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => completer.future); + + await tester.pumpWidget(buildSubject()); + + // First frame shows loading since getSavedPosts hasn't resolved yet + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + completer.complete([]); + await tester.pumpAndSettle(); + }); +} diff --git a/test/features/profile/presentation/profile_screen_test.dart b/test/features/profile/presentation/profile_screen_test.dart --- a/test/features/profile/presentation/profile_screen_test.dart +++ b/test/features/profile/presentation/profile_screen_test.dart @@ -7,6 +7,7 @@ import 'package:lazurite/features/auth/data/models/auth_models.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; +import 'package:lazurite/features/profile/data/profile_action_repository.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; import 'package:mocktail/mocktail.dart'; @@ -15,6 +16,8 @@ class MockProfileBloc extends MockBloc implements ProfileBloc {} class MockFeedBloc extends MockBloc implements FeedBloc {} + +class MockProfileActionRepository extends Mock implements ProfileActionRepository {} void main() { late MockAuthBloc authBloc; @@ -91,6 +94,44 @@ expect(find.text('she/her'), findsOneWidget); expect(find.text('river.example'), findsOneWidget); expect(find.text('Joined March 2024'), findsOneWidget); + }); + + testWidgets('shows Saved Posts button on own profile', (tester) async { + await tester.pumpWidget(buildSubject()); + + expect(find.text('Saved Posts'), findsOneWidget); + }); + + testWidgets('does not show Saved Posts button on other profiles', (tester) async { + const otherProfile = ProfileViewDetailed( + did: 'did:plc:other', + handle: 'other.bsky.social', + displayName: 'Other User', + ); + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); + whenListen( + profileBloc, + const Stream.empty(), + initialState: const ProfileState.loaded(profile: otherProfile), + ); + + final mockProfileActionRepository = MockProfileActionRepository(); + + final widget = MultiRepositoryProvider( + providers: [RepositoryProvider.value(value: mockProfileActionRepository)], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + ], + child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), + ), + ); + + await tester.pumpWidget(widget); + + expect(find.text('Saved Posts'), findsNothing); }); testWidgets('maps tabs to the expected server filters', (tester) async { -- tangled.sh