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: delete posts

* error recovery

+91 -14
+3 -3
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. 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) 18 + - [x] [11. Saved Posts — Cloud Save via AT Protocol](#11-saved-posts--cloud-save-via-at-protocol) 19 + - [x] [12. Failed Action Snackbar with Revert](#12-failed-action-snackbar-with-revert) 20 + - [x] [13. Delete Post — Remove from Feed](#13-delete-post--remove-from-feed) 21 21 22 22 ## 1. Post Thread Screen 23 23
+22 -1
lib/features/feed/cubit/post_action_cubit.dart
··· 16 16 this.repostUri, 17 17 this.isLoadingLike = false, 18 18 this.isLoadingRepost = false, 19 + this.isDeleted = false, 19 20 this.error, 20 21 }); 21 22 ··· 28 29 final String? repostUri; 29 30 final bool isLoadingLike; 30 31 final bool isLoadingRepost; 32 + final bool isDeleted; 31 33 final String? error; 32 34 33 35 PostActionState copyWith({ ··· 39 41 String? repostUri, 40 42 bool? isLoadingLike, 41 43 bool? isLoadingRepost, 44 + bool? isDeleted, 42 45 String? error, 43 46 }) { 44 47 return PostActionState( ··· 51 54 repostUri: repostUri ?? this.repostUri, 52 55 isLoadingLike: isLoadingLike ?? this.isLoadingLike, 53 56 isLoadingRepost: isLoadingRepost ?? this.isLoadingRepost, 57 + isDeleted: isDeleted ?? this.isDeleted, 54 58 error: error ?? this.error, 55 59 ); 56 60 } ··· 66 70 repostUri, 67 71 isLoadingLike, 68 72 isLoadingRepost, 73 + isDeleted, 69 74 error, 70 75 ]; 71 76 } ··· 230 235 Future<void> deletePost() async { 231 236 try { 232 237 await _postActionRepository.deletePost(postUri: state.postUri); 238 + emit(state.copyWith(isDeleted: true)); 233 239 } catch (error) { 234 240 log.e('Failed to delete post', error: error); 235 241 emit(state.copyWith(error: 'Failed to delete post')); ··· 237 243 } 238 244 239 245 void clearError() { 240 - emit(state.copyWith(error: null)); 246 + if (state.error == null) return; 247 + emit( 248 + PostActionState( 249 + postUri: state.postUri, 250 + isLiked: state.isLiked, 251 + isReposted: state.isReposted, 252 + likeCount: state.likeCount, 253 + repostCount: state.repostCount, 254 + likeUri: state.likeUri, 255 + repostUri: state.repostUri, 256 + isLoadingLike: state.isLoadingLike, 257 + isLoadingRepost: state.isLoadingRepost, 258 + isDeleted: state.isDeleted, 259 + error: null, 260 + ), 261 + ); 241 262 } 242 263 }
+9 -1
lib/features/feed/presentation/home_feed_screen.dart
··· 350 350 } 351 351 352 352 final accountDid = context.read<AuthBloc>().state.tokens?.did ?? ''; 353 - return PostCardWithActions(feedViewPost: _posts[index], accountDid: accountDid); 353 + final post = _posts[index]; 354 + return PostCardWithActions( 355 + feedViewPost: post, 356 + accountDid: accountDid, 357 + onDeleted: () { 358 + final uri = post.post.uri.toString(); 359 + setState(() => _posts.removeWhere((p) => p.post.uri.toString() == uri)); 360 + }, 361 + ); 354 362 }, 355 363 ), 356 364 );
+36 -8
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 18 18 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 19 19 20 20 class PostCardWithActions extends StatelessWidget { 21 - const PostCardWithActions({super.key, required this.feedViewPost, required this.accountDid}); 21 + const PostCardWithActions({super.key, required this.feedViewPost, required this.accountDid, this.onDeleted}); 22 22 23 23 final FeedViewPost feedViewPost; 24 24 final String accountDid; 25 + final VoidCallback? onDeleted; 25 26 26 27 @override 27 28 Widget build(BuildContext context) { ··· 41 42 repostUri: viewer?.repost?.toString(), 42 43 cache: ctx.read<PostActionCache>(), 43 44 ), 44 - child: _PostCardWithActionsContent(feedViewPost: feedViewPost, accountDid: accountDid), 45 + child: _PostCardWithActionsContent(feedViewPost: feedViewPost, accountDid: accountDid, onDeleted: onDeleted), 45 46 ); 46 47 } 47 48 } 48 49 49 50 class _PostCardWithActionsContent extends StatelessWidget { 50 - const _PostCardWithActionsContent({required this.feedViewPost, required this.accountDid}); 51 + const _PostCardWithActionsContent({required this.feedViewPost, required this.accountDid, this.onDeleted}); 51 52 52 53 final FeedViewPost feedViewPost; 53 54 final String accountDid; 55 + final VoidCallback? onDeleted; 54 56 55 57 @override 56 58 Widget build(BuildContext context) { 57 59 return BlocListener<PostActionCubit, PostActionState>( 58 - listenWhen: (previous, current) => previous.error != current.error && current.error != null, 60 + listenWhen: (previous, current) => 61 + (previous.error != current.error && current.error != null) || 62 + (!previous.isDeleted && current.isDeleted), 59 63 listener: (context, state) { 64 + if (state.isDeleted) { 65 + ScaffoldMessenger.of(context).showSnackBar( 66 + const SnackBar(content: Text('Post deleted'), behavior: SnackBarBehavior.floating), 67 + ); 68 + onDeleted?.call(); 69 + return; 70 + } 60 71 if (state.error != null) { 61 - ScaffoldMessenger.of( 62 - context, 63 - ).showSnackBar(SnackBar(content: Text(state.error!), behavior: SnackBarBehavior.floating)); 64 - context.read<PostActionCubit>().clearError(); 72 + final cubit = context.read<PostActionCubit>(); 73 + final error = state.error!; 74 + ScaffoldMessenger.of(context).showSnackBar( 75 + SnackBar( 76 + content: Text(error), 77 + behavior: SnackBarBehavior.floating, 78 + action: SnackBarAction( 79 + label: 'Retry', 80 + onPressed: () { 81 + if (error.contains('like')) { 82 + cubit.toggleLike(); 83 + } else if (error.contains('repost')) { 84 + cubit.toggleRepost(); 85 + } else if (error.contains('delete')) { 86 + cubit.deletePost(); 87 + } 88 + }, 89 + ), 90 + ), 91 + ); 92 + cubit.clearError(); 65 93 } 66 94 }, 67 95 child: PostCard(
+21 -1
test/features/feed/cubit/post_action_cubit_test.dart
··· 245 245 246 246 group('deletePost', () { 247 247 blocTest<PostActionCubit, PostActionState>( 248 - 'calls deletePost on repository', 248 + 'emits isDeleted true on success', 249 249 build: () { 250 250 when(() => mockRepository.deletePost(postUri: testPostUri)).thenAnswer((_) async => {}); 251 251 return PostActionCubit(postActionRepository: mockRepository, postUri: testPostUri, postCid: testPostCid); 252 252 }, 253 253 act: (cubit) => cubit.deletePost(), 254 + expect: () => [isA<PostActionState>().having((s) => s.isDeleted, 'isDeleted', isTrue)], 254 255 verify: (_) { 255 256 verify(() => mockRepository.deletePost(postUri: testPostUri)).called(1); 256 257 }, ··· 264 265 }, 265 266 act: (cubit) => cubit.deletePost(), 266 267 expect: () => [isA<PostActionState>().having((s) => s.error, 'error', 'Failed to delete post')], 268 + ); 269 + }); 270 + 271 + group('clearError', () { 272 + blocTest<PostActionCubit, PostActionState>( 273 + 'clears error from state', 274 + build: () => PostActionCubit(postActionRepository: mockRepository, postUri: testPostUri, postCid: testPostCid), 275 + seed: () => const PostActionState(postUri: testPostUri, error: 'some error'), 276 + act: (cubit) => cubit.clearError(), 277 + expect: () => [isA<PostActionState>().having((s) => s.error, 'error', isNull)], 278 + ); 279 + 280 + blocTest<PostActionCubit, PostActionState>( 281 + 'does nothing when error is already null', 282 + build: () => PostActionCubit(postActionRepository: mockRepository, postUri: testPostUri, postCid: testPostCid), 283 + act: (cubit) => cubit.clearError(), 284 + expect: () => [], 267 285 ); 268 286 }); 269 287 }); ··· 419 437 repostUri: testRepostUri, 420 438 isLoadingLike: true, 421 439 isLoadingRepost: false, 440 + isDeleted: false, 422 441 error: 'test error', 423 442 ); 424 443 ··· 432 451 repostUri: testRepostUri, 433 452 isLoadingLike: true, 434 453 isLoadingRepost: false, 454 + isDeleted: false, 435 455 error: 'test error', 436 456 ); 437 457