diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 9ca7332..4557eb3 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -31,6 +31,7 @@ import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; +import 'package:lazurite/shared/presentation/widgets/animated_refresh_indicator.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; class PostThreadScreen extends StatelessWidget { @@ -218,44 +219,48 @@ class _PostThreadContentState extends State<_PostThreadContent> { final replies = _threadRepliesOf(thread); final opDid = (parents.isNotEmpty ? parents.first : thread).post.author.did; - return ListView( - children: [ - for (int i = 0; i < parents.length; i++) ...[ - PostCardWithActions( - feedViewPost: FeedViewPost(post: parents[i].post), - accountDid: accountDid, - onReplySubmitted: _reloadThreadAfterReply, - moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, - ), - _buildThreadConnector(context), - ], - _FocusedPostWithActions(thread: thread, accountDid: accountDid, onReplySubmitted: _reloadThreadAfterReply), - if (replies.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Text( - 'Replies', - style: context.textTheme.labelSmall?.copyWith( - color: context.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - ), - ), - ), - const Divider(height: 1), - for (final reply in replies) - ThreadReplyNode( - key: ValueKey('thread-reply-node-${reply.post.uri}'), - thread: reply, - depth: 1, + return AnimatedRefreshIndicator( + onRefresh: () => context.read().load(widget.postUri), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + for (int i = 0; i < parents.length; i++) ...[ + PostCardWithActions( + feedViewPost: FeedViewPost(post: parents[i].post), accountDid: accountDid, - opDid: opDid, - collapsedUris: _collapsedUris, - onToggleCollapse: _toggleCollapsed, onReplySubmitted: _reloadThreadAfterReply, + moderationContext: bsky_moderation.ModerationBehaviorContext.contentView, + ), + _buildThreadConnector(context), + ], + _FocusedPostWithActions(thread: thread, accountDid: accountDid, onReplySubmitted: _reloadThreadAfterReply), + if (replies.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text( + 'Replies', + style: context.textTheme.labelSmall?.copyWith( + color: context.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), ), + const Divider(height: 1), + for (final reply in replies) + ThreadReplyNode( + key: ValueKey('thread-reply-node-${reply.post.uri}'), + thread: reply, + depth: 1, + accountDid: accountDid, + opDid: opDid, + collapsedUris: _collapsedUris, + onToggleCollapse: _toggleCollapsed, + onReplySubmitted: _reloadThreadAfterReply, + ), + ], ], - ], + ), ); } @@ -848,6 +853,7 @@ class _FocusedPostContent 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, @@ -862,6 +868,12 @@ class _FocusedPostContent extends StatelessWidget { onLongPressSave: () { unawaited(_onToggleSave(context)); }, + onCloudSave: () { + unawaited(_onCloudSave(context)); + }, + onCloudUnsave: () { + unawaited(_onCloudUnsave(context)); + }, onMore: () => _showMoreOptions(context), isOffline: isOffline, ); @@ -921,10 +933,26 @@ class _FocusedPostContent extends StatelessWidget { final cubit = context.read(); final post = thread.post; - await HapticHelper.lightImpact(); + unawaited(HapticHelper.lightImpact()); await cubit.toggleSave(post); } + Future _onCloudSave(BuildContext context) async { + final cubit = context.read(); + final post = thread.post; + + unawaited(HapticHelper.lightImpact()); + await cubit.cloudSave(post); + } + + Future _onCloudUnsave(BuildContext context) async { + final cubit = context.read(); + final post = thread.post; + + unawaited(HapticHelper.lightImpact()); + await cubit.cloudUnsave(post.uri.toString()); + } + void _showMoreOptions(BuildContext context) { final post = thread.post; final repository = context.read(); 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 58fea11..fb633c1 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -296,21 +296,21 @@ class _PostCardWithActionsContent extends StatelessWidget { Future _onToggleSave(BuildContext context) async { final cubit = context.read(); final post = feedViewPost.post; - await HapticHelper.lightImpact(); + unawaited(HapticHelper.lightImpact()); await cubit.toggleSave(post); } Future _onCloudSave(BuildContext context) async { final cubit = context.read(); final post = feedViewPost.post; - await HapticHelper.lightImpact(); + unawaited(HapticHelper.lightImpact()); await cubit.cloudSave(post); } Future _onCloudUnsave(BuildContext context) async { final cubit = context.read(); final post = feedViewPost.post; - await HapticHelper.lightImpact(); + unawaited(HapticHelper.lightImpact()); await cubit.cloudUnsave(post.uri.toString()); } diff --git a/test/features/feed/presentation/post_thread_edit_flow_test.dart b/test/features/feed/presentation/post_thread_edit_flow_test.dart index bc991ac..58f2804 100644 --- a/test/features/feed/presentation/post_thread_edit_flow_test.dart +++ b/test/features/feed/presentation/post_thread_edit_flow_test.dart @@ -18,6 +18,7 @@ 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/data/post_thread_repository.dart'; import 'package:lazurite/features/feed/presentation/post_thread_screen.dart'; +import 'package:lazurite/features/feed/presentation/widgets/post_action_bar.dart'; import 'package:lazurite/features/search/data/search_scope.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -118,6 +119,12 @@ void main() { const postUri = 'at://did:plc:owner/app.bsky.feed.post/root'; + setUpAll(() { + registerFallbackValue( + _makePost(did: 'did:plc:fallback', handle: 'fallback.bsky.social', rkey: 'fallback', text: ''), + ); + }); + setUp(() { postThreadRepository = MockPostThreadRepository(); savedPostsCubit = MockSavedPostsCubit(); @@ -313,4 +320,54 @@ void main() { expect(loadCount, greaterThanOrEqualTo(2)); }); + + testWidgets('pull to refresh reloads the thread', (tester) async { + final thread = _makeThread( + did: 'did:plc:owner', + handle: 'owner.bsky.social', + rkey: 'root', + text: 'Refreshable post body', + ); + + await tester.pumpWidget(createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (_) {})); + await tester.pumpAndSettle(); + + verify(() => postThreadRepository.getPostThread(postUri)).called(1); + + await tester.fling(find.byType(ListView), const Offset(0, 300), 1000); + await tester.pump(); + await tester.pumpAndSettle(); + + verify(() => postThreadRepository.getPostThread(postUri)).called(1); + }); + + testWidgets('focused post save sheet can save to Bluesky', (tester) async { + final thread = _makeThread( + did: 'did:plc:owner', + handle: 'owner.bsky.social', + rkey: 'root', + text: 'Cloud-saveable post body', + ); + when(() => savedPostsCubit.cloudSave(any())).thenAnswer((_) async => true); + + await tester.pumpWidget(createSubjectWidget(accountDid: 'did:plc:owner', thread: thread, onComposeArgs: (_) {})); + await tester.pumpAndSettle(); + + expect(tester.widget(find.byType(PostActionBar)).onCloudSave, isNotNull); + + await tester.tap( + find.descendant(of: find.byType(PostActionBar), matching: find.byIcon(Icons.bookmark_outline)).first, + ); + await tester.pumpAndSettle(); + + final cloudTile = find.widgetWithText(ListTile, 'Save to Bluesky'); + expect(tester.widget(cloudTile).onTap, isNotNull); + + await tester.tap(cloudTile); + await tester.pumpAndSettle(); + + verify( + () => savedPostsCubit.cloudSave(any(that: isA().having((post) => post.uri.toString(), 'uri', postUri))), + ).called(1); + }); }