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: post actions menu

+733 -48
+1
lib/core/network/app_bsky_routing_policy.dart
··· 79 79 'app.bsky.feed.repost': AppBskyProxyMode.bypassProxy, 80 80 'app.bsky.feed.post': AppBskyProxyMode.bypassProxy, 81 81 'app.bsky.feed.getLikes': AppBskyProxyMode.bypassProxy, 82 + 'app.bsky.feed.getQuotes': AppBskyProxyMode.bypassProxy, 82 83 'app.bsky.feed.getRepostedBy': AppBskyProxyMode.bypassProxy, 83 84 'app.bsky.feed.getActorLikes': AppBskyProxyMode.bypassProxy, 84 85 'app.bsky.feed.getListFeed': AppBskyProxyMode.bypassProxy,
+11
lib/features/feed/data/post_action_repository.dart
··· 2 2 import 'package:atproto_core/atproto_core.dart'; 3 3 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 4 4 import 'package:bluesky/app_bsky_feed_getlikes.dart'; 5 + import 'package:bluesky/app_bsky_feed_getquotes.dart'; 5 6 import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 6 7 import 'package:bluesky/bluesky.dart'; 7 8 import 'package:lazurite/core/network/app_view_request_context.dart'; ··· 85 86 limit: 25, 86 87 cursor: cursor, 87 88 $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getRepostedBy'), 89 + ); 90 + return response.data; 91 + } 92 + 93 + Future<FeedGetQuotesOutput> getQuotes({required AtUri uri, String? cursor}) async { 94 + final response = await _bluesky.feed.getQuotes( 95 + uri: uri, 96 + limit: 25, 97 + cursor: cursor, 98 + $headers: _appViewContext.appBskyHeadersForEndpoint('app.bsky.feed.getQuotes'), 88 99 ); 89 100 return response.data; 90 101 }
+15 -48
lib/features/feed/presentation/post_thread_screen.dart
··· 5 5 import 'package:bluesky/app_bsky_feed_post.dart'; 6 6 import 'package:bluesky/moderation.dart' as bsky_moderation; 7 7 import 'package:flutter/material.dart'; 8 - import 'package:flutter/services.dart'; 9 8 import 'package:flutter_bloc/flutter_bloc.dart'; 10 9 import 'package:go_router/go_router.dart'; 11 10 import 'package:intl/intl.dart'; 12 - import 'package:lazurite/core/network/app_view_provider.dart'; 13 - import 'package:lazurite/core/network/app_view_web_links.dart'; 14 11 import 'package:lazurite/core/theme/theme_extensions.dart'; 15 12 import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; 16 13 import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; ··· 24 21 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 25 22 import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; 26 23 import 'package:lazurite/features/feed/presentation/widgets/post_interactions_sheet.dart'; 24 + import 'package:lazurite/features/feed/presentation/widgets/post_menu_actions.dart'; 27 25 import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; 28 26 import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; 29 27 import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; ··· 31 29 import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 32 30 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 33 31 import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 34 - import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 35 32 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 36 33 import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 37 34 import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; ··· 924 921 } 925 922 926 923 void _showMoreOptions(BuildContext context) { 927 - HapticHelper.mediumImpact(); 928 924 final post = thread.post; 929 - final postUri = post.uri.toString(); 930 - final bskyUrl = AppViewWebLinks.postFromAtUri(postUri, appViewProvider: _resolveAppViewProvider(context)); 925 + final repository = context.read<PostActionRepository>(); 926 + final isOffline = context.read<ConnectivityCubit>().state.isOffline; 931 927 932 - showOptionsSheet<void>( 933 - context: context, 934 - items: [ 935 - OptionsSheetItem( 936 - leading: const Icon(Icons.copy), 937 - title: 'Copy Link', 938 - onTap: () => _copyToClipboard(context, bskyUrl), 939 - ), 940 - OptionsSheetItem( 941 - leading: const Icon(Icons.person_outline), 942 - title: 'View @${post.author.handle}', 943 - onTap: () => navigateToProfile(context, post.author.did), 944 - ), 945 - OptionsSheetItem( 946 - leading: const Icon(Icons.report_outlined, color: Colors.orange), 947 - title: 'Report Post', 948 - onTap: () => _showReportDialog(context), 949 - ), 950 - if (post.author.did == accountDid) 951 - OptionsSheetItem(leading: const Icon(Icons.edit_outlined), title: 'Edit Post', onTap: () => _onEdit(context)), 952 - if (post.author.did == accountDid) 953 - OptionsSheetItem( 954 - leading: Icon(Icons.delete_outline, color: context.colorScheme.error), 955 - title: 'Delete Post', 956 - isDestructive: true, 957 - onTap: () => _confirmDelete(context), 958 - ), 959 - ], 928 + unawaited( 929 + showPostOverflowMenu( 930 + context: context, 931 + post: post, 932 + accountDid: accountDid, 933 + repository: repository, 934 + onQuote: () => _onQuote(context), 935 + onShowReport: () => _showReportDialog(context), 936 + onEdit: () => _onEdit(context), 937 + onDelete: () => _confirmDelete(context), 938 + isOffline: isOffline, 939 + ), 960 940 ); 961 941 } 962 942 ··· 1059 1039 ); 1060 1040 } 1061 1041 1062 - void _copyToClipboard(BuildContext context, String text) { 1063 - Clipboard.setData(ClipboardData(text: text)); 1064 - showAppSnackBar(context, 'Link copied to clipboard', behavior: SnackBarBehavior.floating); 1065 - } 1066 - 1067 1042 (String, String) _findRoot() { 1068 1043 var current = thread.parent; 1069 1044 ThreadViewPost? root; ··· 1088 1063 } 1089 1064 final text = record['text']; 1090 1065 return text is String ? text : ''; 1091 - } 1092 - 1093 - String _resolveAppViewProvider(BuildContext context) { 1094 - try { 1095 - return context.read<SettingsCubit>().state.appViewProvider; 1096 - } catch (_) { 1097 - return AppViewProviders.defaultKey; 1098 - } 1099 1066 } 1100 1067 }
+40
lib/features/feed/presentation/widgets/post_card_footer.dart
··· 28 28 this.isLoadingRepost = false, 29 29 this.onReply, 30 30 this.onRepost, 31 + this.onQuote, 31 32 this.onLike, 32 33 this.onSave, 33 34 this.onLongPressSave, 34 35 this.onCloudSave, 35 36 this.onCloudUnsave, 37 + this.onMore, 36 38 this.showCounts = false, 37 39 this.isOffline = false, 38 40 }); ··· 50 52 final bool isLoadingRepost; 51 53 final VoidCallback? onReply; 52 54 final VoidCallback? onRepost; 55 + final VoidCallback? onQuote; 53 56 final VoidCallback? onLike; 54 57 final VoidCallback? onSave; 55 58 final VoidCallback? onLongPressSave; 56 59 final VoidCallback? onCloudSave; 57 60 final VoidCallback? onCloudUnsave; 61 + final VoidCallback? onMore; 58 62 final bool showCounts; 59 63 final bool isOffline; 60 64 ··· 92 96 isLoading: isLoadingRepost, 93 97 count: repostCount, 94 98 onTap: isOffline ? null : onRepost, 99 + onLongPress: !isOffline && onRepost != null ? () => _showRepostOptions(context) : null, 95 100 color: colorScheme.onSurfaceVariant, 96 101 activeColor: Colors.green, 97 102 iconSize: iconSize, ··· 127 132 padding: actionPadding, 128 133 showCount: canShowCounts, 129 134 ), 135 + if (onMore != null) 136 + _FooterAction( 137 + icon: Icons.more_vert, 138 + activeIcon: Icons.more_vert, 139 + isActive: false, 140 + isLoading: false, 141 + count: 0, 142 + onTap: onMore, 143 + color: colorScheme.onSurfaceVariant, 144 + iconSize: iconSize, 145 + padding: actionPadding, 146 + showCount: false, 147 + ), 130 148 ]; 131 149 132 150 return Container( ··· 159 177 ), 160 178 ); 161 179 }, 180 + ); 181 + } 182 + 183 + void _showRepostOptions(BuildContext context) { 184 + HapticHelper.mediumImpact(); 185 + showOptionsSheet<void>( 186 + context: context, 187 + items: [ 188 + OptionsSheetItem( 189 + leading: Icon(Icons.repeat, color: isReposted ? Colors.green : null), 190 + title: isReposted ? 'Unrepost' : 'Repost', 191 + subtitle: isReposted ? 'Remove this repost' : 'Share this post', 192 + onTap: onRepost, 193 + ), 194 + if (!isReposted) 195 + OptionsSheetItem( 196 + leading: const Icon(Icons.format_quote), 197 + title: 'Quote Post', 198 + subtitle: 'Quote this post with your own text', 199 + onTap: onQuote, 200 + ), 201 + ], 162 202 ); 163 203 } 164 204
+60
lib/features/feed/presentation/widgets/post_card_with_actions.dart
··· 15 15 import 'package:lazurite/features/feed/presentation/widgets/grid_post_card.dart'; 16 16 import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 17 17 import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; 18 + import 'package:lazurite/features/feed/presentation/widgets/post_menu_actions.dart'; 19 + import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; 20 + import 'package:lazurite/features/profile/data/profile_action_repository.dart'; 21 + import 'package:lazurite/features/profile/presentation/widgets/report_dialog.dart'; 18 22 import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 19 23 import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 24 + import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; 20 25 21 26 /// Controls which card layout variant is rendered by [PostCardWithActions]. 22 27 enum PostCardVariant { linear, grid } ··· 204 209 isLoadingRepost: postActionState.isLoadingRepost, 205 210 onReply: () => _onReply(context), 206 211 onRepost: () => context.read<PostActionCubit>().toggleRepost(), 212 + onQuote: () => _onQuote(context), 207 213 onLike: () => context.read<PostActionCubit>().toggleLike(), 208 214 onSave: () => unawaited(_onToggleSave(context)), 209 215 onLongPressSave: () => unawaited(_onToggleSave(context)), 210 216 onCloudSave: () => unawaited(_onCloudSave(context)), 211 217 onCloudUnsave: () => unawaited(_onCloudUnsave(context)), 218 + onMore: () => _showMoreOptions(context), 212 219 showCounts: true, 213 220 isOffline: isOffline, 214 221 ); ··· 279 286 final post = feedViewPost.post; 280 287 await HapticHelper.lightImpact(); 281 288 await cubit.cloudUnsave(post.uri.toString()); 289 + } 290 + 291 + void _onQuote(BuildContext context) { 292 + HapticHelper.selectionClick(); 293 + final post = feedViewPost.post; 294 + context.push( 295 + '/compose', 296 + extra: ComposeRouteArgs(quoteUri: post.uri.toString(), quoteCid: post.cid, quoteAuthorHandle: post.author.handle), 297 + ); 298 + } 299 + 300 + void _showMoreOptions(BuildContext context) { 301 + final post = feedViewPost.post; 302 + final isOffline = context.read<ConnectivityCubit>().state.isOffline; 303 + final repository = context.read<PostActionRepository>(); 304 + 305 + unawaited( 306 + showPostOverflowMenu( 307 + context: context, 308 + post: post, 309 + accountDid: accountDid, 310 + repository: repository, 311 + onQuote: () => _onQuote(context), 312 + onShowReport: () => _showReportDialog(context), 313 + onDelete: () => _confirmDelete(context), 314 + isOffline: isOffline, 315 + ), 316 + ); 317 + } 318 + 319 + void _showReportDialog(BuildContext context) { 320 + final post = feedViewPost.post; 321 + showDialog<void>( 322 + context: context, 323 + builder: (_) => BlocProvider( 324 + create: (_) => ProfileActionCubit( 325 + profileActionRepository: context.read<ProfileActionRepository>(), 326 + actorDid: post.author.did, 327 + ), 328 + child: ReportDialog.post(postUri: post.uri, cid: post.cid, authorHandle: post.author.handle), 329 + ), 330 + ); 331 + } 332 + 333 + Future<void> _confirmDelete(BuildContext context) async { 334 + await showConfirmationDialog( 335 + context: context, 336 + title: const Text('Delete Post?'), 337 + content: const Text('This action cannot be undone.'), 338 + confirmLabel: 'Delete', 339 + confirmDestructive: true, 340 + onConfirmed: () => context.read<PostActionCubit>().deletePost(), 341 + ); 282 342 } 283 343 }
+130
lib/features/feed/presentation/widgets/post_menu_actions.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 + import 'package:flutter/material.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:flutter/services.dart'; 5 + import 'package:lazurite/core/network/app_view_provider.dart'; 6 + import 'package:lazurite/core/network/app_view_web_links.dart'; 7 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 8 + import 'package:lazurite/features/feed/presentation/widgets/post_interactions_sheet.dart'; 9 + import 'package:lazurite/features/feed/presentation/widgets/post_quote_repost_sheet.dart'; 10 + import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 11 + import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; 12 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 13 + import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; 14 + import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; 15 + 16 + Future<void> showPostOverflowMenu({ 17 + required BuildContext context, 18 + required PostView post, 19 + required String accountDid, 20 + required PostActionRepository repository, 21 + required VoidCallback onQuote, 22 + required VoidCallback onShowReport, 23 + VoidCallback? onEdit, 24 + VoidCallback? onDelete, 25 + bool isOffline = false, 26 + }) async { 27 + HapticHelper.mediumImpact(); 28 + final postUri = post.uri.toString(); 29 + final bskyUrl = AppViewWebLinks.postFromAtUri(postUri, appViewProvider: _resolveAppViewProvider(context)); 30 + 31 + await showOptionsSheet<void>( 32 + context: context, 33 + isScrollControlled: true, 34 + items: [ 35 + OptionsSheetItem( 36 + leading: const Icon(Icons.favorite_outline), 37 + title: 'Show Liked Users', 38 + subtitle: 'View who liked this post', 39 + enabled: !isOffline, 40 + onTap: () => showLikedUsersSheet(context: context, post: post, repository: repository), 41 + ), 42 + OptionsSheetItem( 43 + leading: const Icon(Icons.repeat), 44 + title: 'Show Quote/Repost List', 45 + subtitle: 'View quote posts and expand reposts', 46 + enabled: !isOffline, 47 + onTap: () => showQuoteRepostSheet(context: context, post: post, repository: repository), 48 + ), 49 + OptionsSheetItem( 50 + leading: const Icon(Icons.format_quote), 51 + title: 'Quote Post', 52 + subtitle: 'Quote this post with your own text', 53 + enabled: !isOffline, 54 + onTap: onQuote, 55 + ), 56 + OptionsSheetItem( 57 + leading: const Icon(Icons.copy), 58 + title: 'Copy Link', 59 + onTap: () => _copyToClipboard(context, bskyUrl), 60 + ), 61 + OptionsSheetItem( 62 + leading: const Icon(Icons.person_outline), 63 + title: 'View @${post.author.handle}', 64 + onTap: () => navigateToProfile(context, post.author.did), 65 + ), 66 + OptionsSheetItem( 67 + leading: const Icon(Icons.report_outlined, color: Colors.orange), 68 + title: 'Report Post', 69 + onTap: onShowReport, 70 + ), 71 + if (post.author.did == accountDid && onEdit != null) 72 + OptionsSheetItem(leading: const Icon(Icons.edit_outlined), title: 'Edit Post', onTap: onEdit), 73 + if (post.author.did == accountDid && onDelete != null) 74 + OptionsSheetItem( 75 + leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 76 + title: 'Delete Post', 77 + isDestructive: true, 78 + onTap: onDelete, 79 + ), 80 + ], 81 + ); 82 + } 83 + 84 + void showLikedUsersSheet({ 85 + required BuildContext context, 86 + required PostView post, 87 + required PostActionRepository repository, 88 + }) { 89 + showAppBottomSheet<void>( 90 + context: context, 91 + isScrollControlled: true, 92 + builder: (_) => PostInteractionsSheet( 93 + postUri: post.uri, 94 + likeCount: post.likeCount ?? 0, 95 + repostCount: post.repostCount ?? 0, 96 + initialTab: InteractionTab.likes, 97 + repository: repository, 98 + ), 99 + ); 100 + } 101 + 102 + void showQuoteRepostSheet({ 103 + required BuildContext context, 104 + required PostView post, 105 + required PostActionRepository repository, 106 + }) { 107 + showAppBottomSheet<void>( 108 + context: context, 109 + isScrollControlled: true, 110 + builder: (_) => PostQuoteRepostSheet( 111 + postUri: post.uri, 112 + quoteCount: post.quoteCount ?? 0, 113 + repostCount: post.repostCount ?? 0, 114 + repository: repository, 115 + ), 116 + ); 117 + } 118 + 119 + void _copyToClipboard(BuildContext context, String text) { 120 + Clipboard.setData(ClipboardData(text: text)); 121 + showAppSnackBar(context, 'Link copied to clipboard', behavior: SnackBarBehavior.floating); 122 + } 123 + 124 + String _resolveAppViewProvider(BuildContext context) { 125 + try { 126 + return context.read<SettingsCubit>().state.appViewProvider; 127 + } catch (_) { 128 + return AppViewProviders.defaultKey; 129 + } 130 + }
+300
lib/features/feed/presentation/widgets/post_quote_repost_sheet.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_post.dart'; 5 + import 'package:flutter/material.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:lazurite/core/cache/lazurite_image_cache.dart'; 8 + import 'package:lazurite/core/theme/theme_extensions.dart'; 9 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 10 + import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; 11 + 12 + class PostQuoteRepostSheet extends StatefulWidget { 13 + const PostQuoteRepostSheet({ 14 + super.key, 15 + required this.postUri, 16 + required this.quoteCount, 17 + required this.repostCount, 18 + required this.repository, 19 + }); 20 + 21 + final AtUri postUri; 22 + final int quoteCount; 23 + final int repostCount; 24 + final PostActionRepository repository; 25 + 26 + @override 27 + State<PostQuoteRepostSheet> createState() => _PostQuoteRepostSheetState(); 28 + } 29 + 30 + class _PostQuoteRepostSheetState extends State<PostQuoteRepostSheet> { 31 + final List<PostView> _quotes = []; 32 + bool _loadingQuotes = false; 33 + String? _quotesCursor; 34 + bool _quotesLoaded = false; 35 + 36 + final List<ProfileView> _reposters = []; 37 + bool _loadingReposts = false; 38 + String? _repostsCursor; 39 + bool _repostsLoaded = false; 40 + bool _repostsExpanded = false; 41 + 42 + @override 43 + void initState() { 44 + super.initState(); 45 + _loadQuotes(); 46 + } 47 + 48 + Future<void> _loadQuotes() async { 49 + if (_loadingQuotes) return; 50 + setState(() => _loadingQuotes = true); 51 + try { 52 + final output = await widget.repository.getQuotes(uri: widget.postUri, cursor: _quotesCursor); 53 + if (mounted) { 54 + setState(() { 55 + _quotes.addAll(output.posts); 56 + _quotesCursor = output.cursor; 57 + _quotesLoaded = true; 58 + }); 59 + } 60 + } catch (_) { 61 + if (mounted) { 62 + setState(() => _quotesLoaded = true); 63 + } 64 + } finally { 65 + if (mounted) { 66 + setState(() => _loadingQuotes = false); 67 + } 68 + } 69 + } 70 + 71 + Future<void> _loadReposts() async { 72 + if (_loadingReposts) return; 73 + setState(() => _loadingReposts = true); 74 + try { 75 + final output = await widget.repository.getRepostedBy(uri: widget.postUri, cursor: _repostsCursor); 76 + if (mounted) { 77 + setState(() { 78 + _reposters.addAll(output.repostedBy); 79 + _repostsCursor = output.cursor; 80 + _repostsLoaded = true; 81 + }); 82 + } 83 + } catch (_) { 84 + if (mounted) { 85 + setState(() => _repostsLoaded = true); 86 + } 87 + } finally { 88 + if (mounted) { 89 + setState(() => _loadingReposts = false); 90 + } 91 + } 92 + } 93 + 94 + void _toggleReposts(bool expanded) { 95 + setState(() => _repostsExpanded = expanded); 96 + if (expanded && !_repostsLoaded) { 97 + _loadReposts(); 98 + } 99 + } 100 + 101 + @override 102 + Widget build(BuildContext context) { 103 + final colorScheme = context.colorScheme; 104 + 105 + return DraggableScrollableSheet( 106 + initialChildSize: 0.72, 107 + maxChildSize: 0.95, 108 + minChildSize: 0.35, 109 + expand: false, 110 + builder: (context, scrollController) { 111 + return ListView( 112 + controller: scrollController, 113 + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), 114 + children: [ 115 + Text( 116 + 'QUOTE / REPOSTS', 117 + style: context.textTheme.labelSmall?.copyWith( 118 + color: colorScheme.onSurfaceVariant, 119 + fontWeight: FontWeight.w700, 120 + letterSpacing: 2.0, 121 + ), 122 + ), 123 + const SizedBox(height: 12), 124 + _buildQuotesGroup(context), 125 + const SizedBox(height: 12), 126 + _buildRepostsGroup(context), 127 + ], 128 + ); 129 + }, 130 + ); 131 + } 132 + 133 + Widget _buildQuotesGroup(BuildContext context) { 134 + final colorScheme = context.colorScheme; 135 + return Container( 136 + decoration: BoxDecoration(border: Border.all(color: colorScheme.outlineVariant)), 137 + child: Column( 138 + crossAxisAlignment: CrossAxisAlignment.start, 139 + children: [ 140 + Padding( 141 + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8), 142 + child: Row( 143 + children: [ 144 + Icon(Icons.format_quote, size: 16, color: colorScheme.onSurfaceVariant), 145 + const SizedBox(width: 8), 146 + Expanded( 147 + child: Text('Quotes', style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700)), 148 + ), 149 + Text( 150 + '${widget.quoteCount}', 151 + style: context.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), 152 + ), 153 + ], 154 + ), 155 + ), 156 + const Divider(height: 1), 157 + if (_loadingQuotes && _quotes.isEmpty) 158 + const Padding( 159 + padding: EdgeInsets.all(20), 160 + child: Center(child: CircularProgressIndicator()), 161 + ) 162 + else if (_quotesLoaded && _quotes.isEmpty) 163 + Padding( 164 + padding: const EdgeInsets.all(16), 165 + child: Text('No quotes yet', style: TextStyle(color: colorScheme.onSurfaceVariant)), 166 + ) 167 + else 168 + Column( 169 + children: [ 170 + for (final quote in _quotes) _QuotePostTile(quote: quote), 171 + if (_quotesCursor != null) 172 + TextButton( 173 + onPressed: _loadingQuotes ? null : _loadQuotes, 174 + child: _loadingQuotes 175 + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 176 + : const Text('Load more quotes'), 177 + ), 178 + ], 179 + ), 180 + ], 181 + ), 182 + ); 183 + } 184 + 185 + Widget _buildRepostsGroup(BuildContext context) { 186 + final colorScheme = context.colorScheme; 187 + return Container( 188 + decoration: BoxDecoration(border: Border.all(color: colorScheme.outlineVariant)), 189 + child: ExpansionTile( 190 + key: const ValueKey('quote-repost-reposts-expansion'), 191 + initiallyExpanded: _repostsExpanded, 192 + onExpansionChanged: _toggleReposts, 193 + iconColor: colorScheme.onSurfaceVariant, 194 + collapsedIconColor: colorScheme.onSurfaceVariant, 195 + leading: Icon(Icons.repeat, color: colorScheme.onSurfaceVariant), 196 + title: Text('Reposts', style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700)), 197 + trailing: Row( 198 + mainAxisSize: MainAxisSize.min, 199 + children: [ 200 + Text('${widget.repostCount}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 201 + const SizedBox(width: 4), 202 + Icon(_repostsExpanded ? Icons.expand_less : Icons.expand_more, color: colorScheme.onSurfaceVariant), 203 + ], 204 + ), 205 + children: [ 206 + const Divider(height: 1), 207 + if (_loadingReposts && _reposters.isEmpty) 208 + const Padding( 209 + padding: EdgeInsets.all(20), 210 + child: Center(child: CircularProgressIndicator()), 211 + ) 212 + else if (_repostsLoaded && _reposters.isEmpty) 213 + Padding( 214 + padding: const EdgeInsets.all(16), 215 + child: Text('No reposts yet', style: TextStyle(color: colorScheme.onSurfaceVariant)), 216 + ) 217 + else 218 + Column( 219 + children: [ 220 + for (final profile in _reposters) _ReposterTile(profile: profile), 221 + if (_repostsCursor != null) 222 + TextButton( 223 + onPressed: _loadingReposts ? null : _loadReposts, 224 + child: _loadingReposts 225 + ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) 226 + : const Text('Load more reposts'), 227 + ), 228 + ], 229 + ), 230 + ], 231 + ), 232 + ); 233 + } 234 + } 235 + 236 + class _QuotePostTile extends StatelessWidget { 237 + const _QuotePostTile({required this.quote}); 238 + 239 + final PostView quote; 240 + 241 + @override 242 + Widget build(BuildContext context) { 243 + final colorScheme = context.colorScheme; 244 + final displayName = quote.author.displayName ?? quote.author.handle; 245 + final text = _quoteTextFromRecord(quote.record); 246 + 247 + return ListTile( 248 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), 249 + leading: CircleAvatar( 250 + backgroundImage: appCachedImageProvider(quote.author.avatar), 251 + backgroundColor: colorScheme.surfaceContainerHighest, 252 + child: quote.author.avatar == null ? Text(displayName.substring(0, 1).toUpperCase()) : null, 253 + ), 254 + title: Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 255 + subtitle: Text(text.isEmpty ? '@${quote.author.handle}' : text, maxLines: 2, overflow: TextOverflow.ellipsis), 256 + trailing: const Icon(Icons.open_in_new, size: 16), 257 + onTap: () { 258 + final router = GoRouter.maybeOf(context); 259 + Navigator.of(context).pop(); 260 + router?.push('/post?uri=${Uri.encodeQueryComponent(quote.uri.toString())}'); 261 + }, 262 + ); 263 + } 264 + 265 + String _quoteTextFromRecord(Map<String, dynamic> record) { 266 + try { 267 + return FeedPostRecord.fromJson(record).text; 268 + } catch (_) { 269 + final value = record['text']; 270 + return value is String ? value : ''; 271 + } 272 + } 273 + } 274 + 275 + class _ReposterTile extends StatelessWidget { 276 + const _ReposterTile({required this.profile}); 277 + 278 + final ProfileView profile; 279 + 280 + @override 281 + Widget build(BuildContext context) { 282 + final colorScheme = context.colorScheme; 283 + final displayName = profile.displayName ?? profile.handle; 284 + 285 + return ListTile( 286 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), 287 + leading: CircleAvatar( 288 + backgroundImage: appCachedImageProvider(profile.avatar), 289 + backgroundColor: colorScheme.surfaceContainerHighest, 290 + child: profile.avatar == null ? Text(displayName.substring(0, 1).toUpperCase()) : null, 291 + ), 292 + title: Text(displayName, maxLines: 1, overflow: TextOverflow.ellipsis), 293 + subtitle: Text('@${profile.handle}', style: TextStyle(color: colorScheme.onSurfaceVariant)), 294 + onTap: () { 295 + Navigator.of(context).pop(); 296 + navigateToProfile(context, profile.did); 297 + }, 298 + ); 299 + } 300 + }
+1
test/core/network/app_bsky_routing_policy_test.dart
··· 12 12 'app.bsky.graph.getLists', 13 13 'app.bsky.feed.getActorLikes', 14 14 'app.bsky.feed.getPosts', 15 + 'app.bsky.feed.getQuotes', 15 16 'app.bsky.unspecced.getTopicFeed', 16 17 ]; 17 18
+25
test/features/feed/data/post_action_repository_test.dart
··· 1 1 import 'package:atproto_core/atproto_core.dart'; 2 2 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 3 3 import 'package:bluesky/app_bsky_feed_getlikes.dart'; 4 + import 'package:bluesky/app_bsky_feed_getquotes.dart'; 4 5 import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 5 6 import 'package:flutter_test/flutter_test.dart'; 6 7 import 'package:lazurite/features/feed/data/post_action_repository.dart'; ··· 63 64 @override 64 65 Future<FeedGetRepostedByOutput> getRepostedBy({required dynamic uri, String? cursor}) async { 65 66 return FeedGetRepostedByOutput(uri: AtUri.parse(uri.toString()), repostedBy: []); 67 + } 68 + 69 + @override 70 + Future<FeedGetQuotesOutput> getQuotes({required dynamic uri, String? cursor}) async { 71 + return FeedGetQuotesOutput(uri: AtUri.parse(uri.toString()), posts: []); 66 72 } 67 73 68 74 bool isLiked(String postUri) => _likes.containsKey(postUri); ··· 257 263 final output = await repository.getRepostedBy(uri: uri, cursor: 'next-page'); 258 264 259 265 expect(output.repostedBy, isEmpty); 266 + }); 267 + }); 268 + 269 + group('getQuotes', () { 270 + test('should return empty quote list', () async { 271 + final uri = _createTestUri('abc123'); 272 + 273 + final output = await repository.getQuotes(uri: uri); 274 + 275 + expect(output.posts, isEmpty); 276 + expect(output.cursor, isNull); 277 + }); 278 + 279 + test('should accept cursor param', () async { 280 + final uri = _createTestUri('abc123'); 281 + 282 + final output = await repository.getQuotes(uri: uri, cursor: 'next-page'); 283 + 284 + expect(output.posts, isEmpty); 260 285 }); 261 286 }); 262 287 });
+6
test/features/feed/presentation/post_interactions_sheet_test.dart
··· 2 2 import 'package:bluesky/app_bsky_actor_defs.dart'; 3 3 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 4 4 import 'package:bluesky/app_bsky_feed_getlikes.dart'; 5 + import 'package:bluesky/app_bsky_feed_getquotes.dart'; 5 6 import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 6 7 import 'package:flutter/material.dart'; 7 8 import 'package:flutter_test/flutter_test.dart'; ··· 21 22 @override 22 23 Future<FeedGetRepostedByOutput> getRepostedBy({required AtUri uri, String? cursor}) async { 23 24 return FeedGetRepostedByOutput(uri: uri, repostedBy: reposters); 25 + } 26 + 27 + @override 28 + Future<FeedGetQuotesOutput> getQuotes({required AtUri uri, String? cursor}) async { 29 + return FeedGetQuotesOutput(uri: uri, posts: []); 24 30 } 25 31 26 32 @override
+123
test/features/feed/presentation/post_quote_repost_sheet_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:bluesky/app_bsky_feed_getlikes.dart'; 6 + import 'package:bluesky/app_bsky_feed_getquotes.dart'; 7 + import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 8 + import 'package:flutter/material.dart'; 9 + import 'package:flutter_test/flutter_test.dart'; 10 + import 'package:lazurite/core/theme/app_theme.dart'; 11 + import 'package:lazurite/features/feed/data/post_action_repository.dart'; 12 + import 'package:lazurite/features/feed/presentation/widgets/post_quote_repost_sheet.dart'; 13 + 14 + class _FakeRepository implements PostActionRepository { 15 + _FakeRepository({this.quotes = const [], this.reposters = const []}); 16 + 17 + final List<PostView> quotes; 18 + final List<ProfileView> reposters; 19 + 20 + @override 21 + Future<FeedGetQuotesOutput> getQuotes({required AtUri uri, String? cursor}) async { 22 + return FeedGetQuotesOutput(uri: uri, posts: quotes); 23 + } 24 + 25 + @override 26 + Future<FeedGetRepostedByOutput> getRepostedBy({required AtUri uri, String? cursor}) async { 27 + return FeedGetRepostedByOutput(uri: uri, repostedBy: reposters); 28 + } 29 + 30 + @override 31 + Future<FeedGetLikesOutput> getLikes({required AtUri uri, String? cursor}) async { 32 + return FeedGetLikesOutput(uri: uri, likes: []); 33 + } 34 + 35 + @override 36 + Future<String> likePost({required AtUri uri, required String cid}) async => ''; 37 + 38 + @override 39 + Future<void> unlikePost({required String likeUri}) async {} 40 + 41 + @override 42 + Future<String> repostPost({required AtUri uri, required String cid}) async => ''; 43 + 44 + @override 45 + Future<void> unrepostPost({required String repostUri}) async {} 46 + 47 + @override 48 + Future<void> deletePost({required String postUri}) async {} 49 + 50 + @override 51 + Future<void> createBookmark({required AtUri uri, required String cid}) async {} 52 + 53 + @override 54 + Future<void> deleteBookmark({required AtUri uri}) async {} 55 + 56 + @override 57 + Future<BookmarkGetBookmarksOutput> getBookmarks({int? limit, String? cursor}) async { 58 + return const BookmarkGetBookmarksOutput(bookmarks: []); 59 + } 60 + } 61 + 62 + PostView _makeQuotePost({required String rkey, required String text, required String handle}) { 63 + return PostView( 64 + uri: AtUri('at://did:plc:$rkey/app.bsky.feed.post/$rkey'), 65 + cid: 'cid-$rkey', 66 + author: ProfileViewBasic(did: 'did:plc:$rkey', handle: handle, displayName: handle.split('.').first), 67 + record: {r'$type': 'app.bsky.feed.post', 'text': text, 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String()}, 68 + indexedAt: DateTime.utc(2026, 3, 15), 69 + ); 70 + } 71 + 72 + ProfileView _makeProfile({required String handle}) { 73 + return ProfileView(did: 'did:plc:$handle', handle: handle, displayName: handle.split('.').first); 74 + } 75 + 76 + Widget _buildSheet(PostActionRepository repository) { 77 + final theme = AppTheme.getTheme(AppThemePalette.oxocarbon, AppThemeVariant.dark); 78 + return MaterialApp( 79 + theme: theme, 80 + home: Scaffold( 81 + body: PostQuoteRepostSheet( 82 + postUri: AtUri.parse('at://did:plc:test/app.bsky.feed.post/abc'), 83 + quoteCount: 1, 84 + repostCount: 2, 85 + repository: repository, 86 + ), 87 + ), 88 + ); 89 + } 90 + 91 + void main() { 92 + group('PostQuoteRepostSheet', () { 93 + testWidgets('renders grouped quote and repost sections', (tester) async { 94 + await tester.pumpWidget( 95 + _buildSheet( 96 + _FakeRepository( 97 + quotes: [_makeQuotePost(rkey: 'quote1', text: 'Quoted post', handle: 'q.bsky.social')], 98 + ), 99 + ), 100 + ); 101 + await tester.pump(); 102 + await tester.pump(); 103 + 104 + expect(find.text('QUOTE / REPOSTS'), findsOneWidget); 105 + expect(find.text('Quotes'), findsOneWidget); 106 + expect(find.text('Reposts'), findsOneWidget); 107 + expect(find.text('Quoted post'), findsOneWidget); 108 + }); 109 + 110 + testWidgets('expands repost group and shows reposter list', (tester) async { 111 + await tester.pumpWidget(_buildSheet(_FakeRepository(reposters: [_makeProfile(handle: 'alice.bsky.social')]))); 112 + await tester.pump(); 113 + await tester.pump(); 114 + 115 + await tester.tap(find.text('Reposts')); 116 + await tester.pump(); 117 + await tester.pump(); 118 + 119 + expect(find.text('alice'), findsOneWidget); 120 + expect(find.text('@alice.bsky.social'), findsOneWidget); 121 + }); 122 + }); 123 + }
+6
test/features/feed/presentation/post_thread_edit_flow_test.dart
··· 4 4 import 'package:bluesky/app_bsky_bookmark_getbookmarks.dart'; 5 5 import 'package:bluesky/app_bsky_feed_defs.dart'; 6 6 import 'package:bluesky/app_bsky_feed_getlikes.dart'; 7 + import 'package:bluesky/app_bsky_feed_getquotes.dart'; 7 8 import 'package:bluesky/app_bsky_feed_getrepostedby.dart'; 8 9 import 'package:flutter/material.dart'; 9 10 import 'package:flutter_bloc/flutter_bloc.dart'; ··· 53 54 @override 54 55 Future<FeedGetRepostedByOutput> getRepostedBy({required AtUri uri, String? cursor}) async { 55 56 return FeedGetRepostedByOutput(uri: uri, repostedBy: []); 57 + } 58 + 59 + @override 60 + Future<FeedGetQuotesOutput> getQuotes({required AtUri uri, String? cursor}) async { 61 + return FeedGetQuotesOutput(uri: uri, posts: []); 56 62 } 57 63 58 64 @override