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: Add 'Saved Posts' button to own profile and update saved posts screen

+249 -55
+2 -2
docs/BUGS.md
··· 11 11 - [x] [4. Quoted Post Tap Navigation](#4-quoted-post-tap-navigation) 12 12 - [x] [5. Notification Tap Navigation](#5-notification-tap-navigation) 13 13 - [x] [6. Viewer State on Own Posts](#6-viewer-state-on-own-posts) 14 - - [ ] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) 15 - - [ ] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 14 + - [x] [7. Saved Posts Screen — Render Actual Posts](#7-saved-posts-screen--render-actual-posts) 15 + - [x] [8. Saved Posts — Accessible from Profile](#8-saved-posts--accessible-from-profile) 16 16 - [ ] [9. Saved Posts — Long Press for Local, Tap for Menu](#9-saved-posts--long-press-for-local-tap-for-menu) 17 17 - [ ] [10. Saved Posts — Show Save Counts](#10-saved-posts--show-save-counts) 18 18 - [ ] [11. Failed Action Snackbar with Revert](#11-failed-action-snackbar-with-revert)
+44 -52
lib/features/feed/presentation/saved_posts_screen.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 1 4 import 'package:flutter/material.dart'; 2 5 import 'package:flutter_bloc/flutter_bloc.dart'; 3 6 import 'package:go_router/go_router.dart'; 4 7 import 'package:lazurite/core/database/app_database.dart'; 5 8 import 'package:lazurite/core/logging/app_logger.dart'; 6 9 import 'package:lazurite/features/feed/cubit/saved_posts_cubit.dart'; 10 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 7 11 import 'package:share_plus/share_plus.dart'; 8 12 9 13 class SavedPostsScreen extends StatelessWidget { ··· 140 144 final SavedPostEntry savedPost; 141 145 final VoidCallback onUnsave; 142 146 147 + FeedViewPost? _deserializePost() { 148 + try { 149 + final json = jsonDecode(savedPost.postJson) as Map<String, dynamic>; 150 + return FeedViewPost(post: PostView.fromJson(json)); 151 + } catch (e) { 152 + log.e('Failed to deserialize saved post', error: e); 153 + return null; 154 + } 155 + } 156 + 143 157 @override 144 158 Widget build(BuildContext context) { 159 + final feedViewPost = _deserializePost(); 160 + final accountDid = context.read<String>(); 161 + 145 162 return Dismissible( 146 163 key: ValueKey(savedPost.id), 147 164 direction: DismissDirection.endToStart, ··· 149 166 alignment: Alignment.centerRight, 150 167 padding: const EdgeInsets.only(right: 16), 151 168 color: Theme.of(context).colorScheme.error, 152 - child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onError), 169 + child: Icon(Icons.bookmark_remove, color: Theme.of(context).colorScheme.onError), 153 170 ), 154 171 onDismissed: (_) => onUnsave(), 155 - child: Card( 156 - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 157 - child: ListTile( 158 - leading: const Icon(Icons.bookmark), 159 - title: const Text('Saved Post'), 160 - subtitle: Text('Saved on ${_formatDate(savedPost.savedAt)}', style: Theme.of(context).textTheme.bodySmall), 161 - trailing: Row( 162 - mainAxisSize: MainAxisSize.min, 163 - children: [ 164 - IconButton( 165 - icon: const Icon(Icons.open_in_new), 166 - onPressed: () => _openPost(context), 167 - tooltip: 'Open post', 168 - ), 169 - IconButton( 170 - icon: const Icon(Icons.share_outlined), 171 - onPressed: () => _sharePost(context), 172 - tooltip: 'Share', 173 - ), 174 - IconButton( 175 - icon: const Icon(Icons.delete_outline), 176 - onPressed: () => _confirmUnsave(context), 177 - tooltip: 'Remove', 178 - ), 179 - ], 180 - ), 181 - ), 182 - ), 172 + child: feedViewPost != null 173 + ? PostCardWithActions(feedViewPost: feedViewPost, accountDid: accountDid) 174 + : _buildFallback(context), 183 175 ); 184 176 } 185 177 186 - void _openPost(BuildContext context) => context.push('/post/${Uri.encodeComponent(savedPost.postUri)}'); 187 - 188 - void _sharePost(BuildContext context) { 189 - final bskyUrl = _convertAtUriToBskyUrl(savedPost.postUri); 190 - Share.share(bskyUrl); 191 - } 192 - 193 - void _confirmUnsave(BuildContext context) { 194 - showDialog<void>( 195 - context: context, 196 - builder: (context) => AlertDialog( 197 - title: const Text('Remove Saved Post?'), 198 - content: const Text('This will remove the post from your saved list.'), 199 - actions: [ 200 - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 201 - FilledButton( 202 - onPressed: () { 203 - Navigator.pop(context); 204 - onUnsave(); 205 - }, 206 - child: const Text('Remove'), 207 - ), 208 - ], 178 + Widget _buildFallback(BuildContext context) { 179 + return Card( 180 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 181 + child: ListTile( 182 + leading: const Icon(Icons.bookmark), 183 + title: const Text('Saved Post'), 184 + subtitle: Text('Saved on ${_formatDate(savedPost.savedAt)}', style: Theme.of(context).textTheme.bodySmall), 185 + trailing: Row( 186 + mainAxisSize: MainAxisSize.min, 187 + children: [ 188 + IconButton( 189 + icon: const Icon(Icons.open_in_new), 190 + onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(savedPost.postUri)}'), 191 + tooltip: 'Open post', 192 + ), 193 + IconButton( 194 + icon: const Icon(Icons.share_outlined), 195 + onPressed: () => Share.share(_convertAtUriToBskyUrl(savedPost.postUri)), 196 + tooltip: 'Share', 197 + ), 198 + IconButton(icon: const Icon(Icons.delete_outline), onPressed: onUnsave, tooltip: 'Remove'), 199 + ], 200 + ), 209 201 ), 210 202 ); 211 203 }
+10 -1
lib/features/profile/presentation/profile_screen.dart
··· 91 91 return BlocBuilder<FeedBloc, FeedState>( 92 92 builder: (context, feedState) { 93 93 final profile = profileState.profile; 94 - final isOwnProfile = profile?.did == _resolvedActor; 94 + final currentUserDid = context.read<AuthBloc>().state.tokens?.did; 95 + final isOwnProfile = profile?.did == currentUserDid; 95 96 96 97 return NestedScrollView( 97 98 headerSliverBuilder: (context, innerBoxIsScrolled) { ··· 239 240 _buildStat(context, profile.postsCount ?? 0, 'Posts'), 240 241 ], 241 242 ), 243 + if (isOwnProfile) ...[ 244 + const SizedBox(height: 16), 245 + OutlinedButton.icon( 246 + onPressed: () => context.push('/saved'), 247 + icon: const Icon(Icons.bookmark_outline), 248 + label: const Text('Saved Posts'), 249 + ), 250 + ], 242 251 if (!isOwnProfile) ...[const SizedBox(height: 16), _buildProfileActions(context, profile)], 243 252 ], 244 253 ),
+152
test/features/feed/presentation/saved_posts_screen_test.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + 4 + import 'package:atproto_core/atproto_core.dart'; 5 + import 'package:bluesky/app_bsky_actor_defs.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:flutter/material.dart'; 8 + import 'package:flutter_bloc/flutter_bloc.dart'; 9 + import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:lazurite/core/database/app_database.dart'; 11 + import 'package:lazurite/features/feed/cubit/post_action_cache.dart'; 12 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 13 + import 'package:lazurite/features/feed/presentation/saved_posts_screen.dart'; 14 + import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 15 + import 'package:mocktail/mocktail.dart'; 16 + 17 + class MockAppDatabase extends Mock implements AppDatabase {} 18 + 19 + class MockPostActionRepository extends Mock implements PostActionRepository {} 20 + 21 + PostView _makePostView({ 22 + String did = 'did:plc:author', 23 + String handle = 'author.bsky.social', 24 + String rkey = 'abc123', 25 + String text = 'Hello world', 26 + }) { 27 + return PostView( 28 + uri: AtUri('at://$did/app.bsky.feed.post/$rkey'), 29 + cid: 'cid-$rkey', 30 + author: ProfileViewBasic(did: did, handle: handle), 31 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String()}, 32 + indexedAt: DateTime.utc(2026, 3, 15), 33 + ); 34 + } 35 + 36 + SavedPostEntry _makeEntry({ 37 + int id = 1, 38 + String postUri = 'at://did:plc:author/app.bsky.feed.post/abc123', 39 + required String postJson, 40 + }) { 41 + return SavedPostEntry( 42 + id: id, 43 + accountDid: 'did:plc:me', 44 + postUri: postUri, 45 + postJson: postJson, 46 + savedAt: DateTime.utc(2026, 3, 15), 47 + ); 48 + } 49 + 50 + void main() { 51 + late MockAppDatabase mockDatabase; 52 + late MockPostActionRepository mockPostActionRepository; 53 + 54 + const testAccountDid = 'did:plc:me'; 55 + 56 + setUp(() { 57 + mockDatabase = MockAppDatabase(); 58 + mockPostActionRepository = MockPostActionRepository(); 59 + 60 + // Default stubs: empty saved posts 61 + when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({})); 62 + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([])); 63 + }); 64 + 65 + Widget buildSubject() { 66 + return MultiRepositoryProvider( 67 + providers: [ 68 + RepositoryProvider<AppDatabase>.value(value: mockDatabase), 69 + RepositoryProvider<PostActionRepository>.value(value: mockPostActionRepository), 70 + RepositoryProvider<PostActionCache>(create: (_) => PostActionCache()), 71 + RepositoryProvider<String>.value(value: testAccountDid), 72 + ], 73 + child: const MaterialApp(home: SavedPostsScreen(accountDid: testAccountDid)), 74 + ); 75 + } 76 + 77 + testWidgets('shows empty state when no saved posts', (tester) async { 78 + await tester.pumpWidget(buildSubject()); 79 + await tester.pump(); 80 + 81 + expect(find.text('No saved posts'), findsOneWidget); 82 + expect(find.text('Posts you save will appear here'), findsOneWidget); 83 + }); 84 + 85 + testWidgets('renders PostCardWithActions for a saved post with valid postJson', (tester) async { 86 + final postView = _makePostView(text: 'Saved post content'); 87 + final postJson = jsonEncode(postView.toJson()); 88 + final entry = _makeEntry(postUri: postView.uri.toString(), postJson: postJson); 89 + 90 + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); 91 + when( 92 + () => mockDatabase.watchSavedPostUris(testAccountDid), 93 + ).thenAnswer((_) => Stream.value({postView.uri.toString()})); 94 + 95 + await tester.pumpWidget(buildSubject()); 96 + await tester.pump(); 97 + 98 + expect(find.byType(PostCardWithActions), findsOneWidget); 99 + }); 100 + 101 + testWidgets('renders fallback card when postJson is invalid', (tester) async { 102 + final entry = _makeEntry(postJson: 'not valid json {{{'); 103 + 104 + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => Future.value([entry])); 105 + when(() => mockDatabase.watchSavedPostUris(testAccountDid)).thenAnswer((_) => Stream.value({entry.postUri})); 106 + 107 + await tester.pumpWidget(buildSubject()); 108 + await tester.pump(); 109 + 110 + expect(find.text('Saved Post'), findsOneWidget); 111 + expect(find.byType(PostCardWithActions), findsNothing); 112 + }); 113 + 114 + testWidgets('swipe to dismiss calls unsavePostById', (tester) async { 115 + final postView = _makePostView(); 116 + final postJson = jsonEncode(postView.toJson()); 117 + final entry = _makeEntry(postUri: postView.uri.toString(), postJson: postJson); 118 + 119 + var callCount = 0; 120 + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) { 121 + callCount++; 122 + return Future.value(callCount == 1 ? [entry] : <SavedPostEntry>[]); 123 + }); 124 + when( 125 + () => mockDatabase.watchSavedPostUris(testAccountDid), 126 + ).thenAnswer((_) => Stream.value({postView.uri.toString()})); 127 + when(() => mockDatabase.unsavePostById(entry.id)).thenAnswer((_) => Future.value(1)); 128 + 129 + await tester.pumpWidget(buildSubject()); 130 + await tester.pump(); 131 + 132 + expect(find.byType(Dismissible), findsOneWidget); 133 + 134 + await tester.drag(find.byType(Dismissible), const Offset(-500, 0)); 135 + await tester.pumpAndSettle(); 136 + 137 + verify(() => mockDatabase.unsavePostById(entry.id)).called(1); 138 + }); 139 + 140 + testWidgets('shows loading indicator while loading', (tester) async { 141 + final completer = Completer<List<SavedPostEntry>>(); 142 + when(() => mockDatabase.getSavedPosts(testAccountDid)).thenAnswer((_) => completer.future); 143 + 144 + await tester.pumpWidget(buildSubject()); 145 + 146 + // First frame shows loading since getSavedPosts hasn't resolved yet 147 + expect(find.byType(CircularProgressIndicator), findsOneWidget); 148 + 149 + completer.complete([]); 150 + await tester.pumpAndSettle(); 151 + }); 152 + }
+41
test/features/profile/presentation/profile_screen_test.dart
··· 7 7 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 8 import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 9 9 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 10 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 10 11 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 11 12 import 'package:mocktail/mocktail.dart'; 12 13 ··· 15 16 class MockProfileBloc extends MockBloc<ProfileEvent, ProfileState> implements ProfileBloc {} 16 17 17 18 class MockFeedBloc extends MockBloc<FeedEvent, FeedState> implements FeedBloc {} 19 + 20 + class MockProfileActionRepository extends Mock implements ProfileActionRepository {} 18 21 19 22 void main() { 20 23 late MockAuthBloc authBloc; ··· 91 94 expect(find.text('she/her'), findsOneWidget); 92 95 expect(find.text('river.example'), findsOneWidget); 93 96 expect(find.text('Joined March 2024'), findsOneWidget); 97 + }); 98 + 99 + testWidgets('shows Saved Posts button on own profile', (tester) async { 100 + await tester.pumpWidget(buildSubject()); 101 + 102 + expect(find.text('Saved Posts'), findsOneWidget); 103 + }); 104 + 105 + testWidgets('does not show Saved Posts button on other profiles', (tester) async { 106 + const otherProfile = ProfileViewDetailed( 107 + did: 'did:plc:other', 108 + handle: 'other.bsky.social', 109 + displayName: 'Other User', 110 + ); 111 + when(() => profileBloc.state).thenReturn(const ProfileState.loaded(profile: otherProfile)); 112 + whenListen( 113 + profileBloc, 114 + const Stream<ProfileState>.empty(), 115 + initialState: const ProfileState.loaded(profile: otherProfile), 116 + ); 117 + 118 + final mockProfileActionRepository = MockProfileActionRepository(); 119 + 120 + final widget = MultiRepositoryProvider( 121 + providers: [RepositoryProvider<ProfileActionRepository>.value(value: mockProfileActionRepository)], 122 + child: MultiBlocProvider( 123 + providers: [ 124 + BlocProvider<AuthBloc>.value(value: authBloc), 125 + BlocProvider<ProfileBloc>.value(value: profileBloc), 126 + BlocProvider<FeedBloc>.value(value: feedBloc), 127 + ], 128 + child: const MaterialApp(home: ProfileScreen(actor: 'did:plc:other', showBackButton: true)), 129 + ), 130 + ); 131 + 132 + await tester.pumpWidget(widget); 133 + 134 + expect(find.text('Saved Posts'), findsNothing); 94 135 }); 95 136 96 137 testWidgets('maps tabs to the expected server filters', (tester) async {