✨ Post actions on post detail page
This commit is contained in:
		| @@ -1,16 +1,26 @@ | |||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter/services.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
|  | import 'package:go_router/go_router.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/models/post.dart'; | import 'package:island/models/post.dart'; | ||||||
| import 'package:island/pods/network.dart'; | import 'package:island/pods/network.dart'; | ||||||
| import 'package:island/pods/userinfo.dart'; | import 'package:island/pods/userinfo.dart'; | ||||||
|  | import 'package:island/screens/posts/compose.dart'; | ||||||
|  | import 'package:island/widgets/alert.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.dart'; | import 'package:island/widgets/app_scaffold.dart'; | ||||||
| import 'package:island/widgets/extended_refresh_indicator.dart'; | import 'package:island/widgets/extended_refresh_indicator.dart'; | ||||||
| import 'package:island/widgets/post/post_item.dart'; | import 'package:island/widgets/post/post_item.dart'; | ||||||
|  | import 'package:island/widgets/post/post_pin_sheet.dart'; | ||||||
| import 'package:island/widgets/post/post_quick_reply.dart'; | import 'package:island/widgets/post/post_quick_reply.dart'; | ||||||
| import 'package:island/widgets/post/post_replies.dart'; | import 'package:island/widgets/post/post_replies.dart'; | ||||||
| import 'package:island/widgets/response.dart'; | import 'package:island/widgets/response.dart'; | ||||||
|  | import 'package:island/utils/share_utils.dart'; | ||||||
|  | import 'package:island/widgets/safety/abuse_report_helper.dart'; | ||||||
|  | import 'package:island/widgets/share/share_sheet.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:riverpod_annotation/riverpod_annotation.dart'; | import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
| @@ -47,6 +57,315 @@ class PostState extends StateNotifier<AsyncValue<SnPost?>> { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | class PostActionButtons extends HookConsumerWidget { | ||||||
|  |   final SnPost post; | ||||||
|  |   final VoidCallback? onRefresh; | ||||||
|  |   final Function(SnPost)? onUpdate; | ||||||
|  |  | ||||||
|  |   const PostActionButtons({ | ||||||
|  |     super.key, | ||||||
|  |     required this.post, | ||||||
|  |     this.onRefresh, | ||||||
|  |     this.onUpdate, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context, WidgetRef ref) { | ||||||
|  |     final user = ref.watch(userInfoProvider); | ||||||
|  |     final isAuthor = | ||||||
|  |         user.value != null && user.value?.id == post.publisher.accountId; | ||||||
|  |  | ||||||
|  |     final actions = <Widget>[]; | ||||||
|  |  | ||||||
|  |     const kButtonHeight = 40.0; | ||||||
|  |     const kButtonRadius = 20.0; | ||||||
|  |  | ||||||
|  |     // 1. Author-only actions first | ||||||
|  |     if (isAuthor) { | ||||||
|  |       // Combined edit/delete actions using custom segmented-style buttons | ||||||
|  |       final editButtons = <Widget>[ | ||||||
|  |         FilledButton.tonal( | ||||||
|  |           onPressed: () { | ||||||
|  |             context.pushNamed('postEdit', pathParameters: {'id': post.id}).then( | ||||||
|  |               (value) { | ||||||
|  |                 if (value != null) { | ||||||
|  |                   onRefresh?.call(); | ||||||
|  |                 } | ||||||
|  |               }, | ||||||
|  |             ); | ||||||
|  |           }, | ||||||
|  |           style: FilledButton.styleFrom( | ||||||
|  |             shape: const RoundedRectangleBorder( | ||||||
|  |               borderRadius: BorderRadius.only( | ||||||
|  |                 topLeft: Radius.circular(kButtonRadius), | ||||||
|  |                 bottomLeft: Radius.circular(kButtonRadius), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           child: Row( | ||||||
|  |             mainAxisSize: MainAxisSize.min, | ||||||
|  |             children: [ | ||||||
|  |               const Icon(Symbols.edit, size: 18), | ||||||
|  |               const Gap(4), | ||||||
|  |               Text('edit'.tr()), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         Tooltip( | ||||||
|  |           message: 'delete'.tr(), | ||||||
|  |           child: FilledButton.tonal( | ||||||
|  |             onPressed: () { | ||||||
|  |               showConfirmAlert('deletePostHint'.tr(), 'deletePost'.tr()).then(( | ||||||
|  |                 confirm, | ||||||
|  |               ) { | ||||||
|  |                 if (confirm) { | ||||||
|  |                   final client = ref.watch(apiClientProvider); | ||||||
|  |                   client | ||||||
|  |                       .delete('/sphere/posts/${post.id}') | ||||||
|  |                       .catchError((err) { | ||||||
|  |                         showErrorAlert(err); | ||||||
|  |                         return err; | ||||||
|  |                       }) | ||||||
|  |                       .then((_) { | ||||||
|  |                         onRefresh?.call(); | ||||||
|  |                       }); | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             style: FilledButton.styleFrom( | ||||||
|  |               shape: const RoundedRectangleBorder( | ||||||
|  |                 borderRadius: BorderRadius.only( | ||||||
|  |                   topRight: Radius.circular(kButtonRadius), | ||||||
|  |                   bottomRight: Radius.circular(kButtonRadius), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             child: const Icon(Symbols.delete, size: 18), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ]; | ||||||
|  |  | ||||||
|  |       actions.add( | ||||||
|  |         Row( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           children: | ||||||
|  |               editButtons | ||||||
|  |                   .map((e) => SizedBox(height: kButtonHeight, child: e)) | ||||||
|  |                   .toList(), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       // Pin/Unpin actions (also author-only) | ||||||
|  |       if (post.pinMode == null) { | ||||||
|  |         actions.add( | ||||||
|  |           FilledButton.tonalIcon( | ||||||
|  |             onPressed: () { | ||||||
|  |               showModalBottomSheet( | ||||||
|  |                 context: context, | ||||||
|  |                 isScrollControlled: true, | ||||||
|  |                 builder: (context) => PostPinSheet(post: post), | ||||||
|  |               ).then((value) { | ||||||
|  |                 if (value is int) { | ||||||
|  |                   onUpdate?.call(post.copyWith(pinMode: value)); | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             icon: const Icon(Symbols.keep), | ||||||
|  |             label: Text('pinPost'.tr()), | ||||||
|  |           ), | ||||||
|  |         ); | ||||||
|  |       } else { | ||||||
|  |         actions.add( | ||||||
|  |           FilledButton.tonalIcon( | ||||||
|  |             onPressed: () { | ||||||
|  |               showConfirmAlert('unpinPostHint'.tr(), 'unpinPost'.tr()).then(( | ||||||
|  |                 confirm, | ||||||
|  |               ) async { | ||||||
|  |                 if (confirm) { | ||||||
|  |                   final client = ref.watch(apiClientProvider); | ||||||
|  |                   try { | ||||||
|  |                     if (context.mounted) showLoadingModal(context); | ||||||
|  |                     await client.delete('/sphere/posts/${post.id}/pin'); | ||||||
|  |                     onUpdate?.call(post.copyWith(pinMode: null)); | ||||||
|  |                   } catch (err) { | ||||||
|  |                     showErrorAlert(err); | ||||||
|  |                   } finally { | ||||||
|  |                     if (context.mounted) hideLoadingModal(context); | ||||||
|  |                   } | ||||||
|  |                 } | ||||||
|  |               }); | ||||||
|  |             }, | ||||||
|  |             icon: const Icon(Symbols.keep_off), | ||||||
|  |             label: Text('unpinPost'.tr()), | ||||||
|  |           ), | ||||||
|  |         ); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 2. Replies and forwards | ||||||
|  |     final replyButtons = <Widget>[ | ||||||
|  |       FilledButton.tonal( | ||||||
|  |         onPressed: () { | ||||||
|  |           context.pushNamed( | ||||||
|  |             'postCompose', | ||||||
|  |             extra: PostComposeInitialState(replyingTo: post), | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |         style: FilledButton.styleFrom( | ||||||
|  |           shape: const RoundedRectangleBorder( | ||||||
|  |             borderRadius: BorderRadius.only( | ||||||
|  |               topLeft: Radius.circular(kButtonRadius), | ||||||
|  |               bottomLeft: Radius.circular(kButtonRadius), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         child: Row( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           children: [ | ||||||
|  |             const Icon(Symbols.reply, size: 18), | ||||||
|  |             const Gap(4), | ||||||
|  |             Text('reply'.tr()), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |       Tooltip( | ||||||
|  |         message: 'forward'.tr(), | ||||||
|  |         child: FilledButton.tonal( | ||||||
|  |           onPressed: () { | ||||||
|  |             context.pushNamed( | ||||||
|  |               'postCompose', | ||||||
|  |               extra: PostComposeInitialState(forwardingTo: post), | ||||||
|  |             ); | ||||||
|  |           }, | ||||||
|  |           style: FilledButton.styleFrom( | ||||||
|  |             shape: const RoundedRectangleBorder( | ||||||
|  |               borderRadius: BorderRadius.only( | ||||||
|  |                 topRight: Radius.circular(kButtonRadius), | ||||||
|  |                 bottomRight: Radius.circular(kButtonRadius), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           child: const Icon(Symbols.forward, size: 18), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     actions.add( | ||||||
|  |       Row( | ||||||
|  |         mainAxisSize: MainAxisSize.min, | ||||||
|  |         children: | ||||||
|  |             replyButtons | ||||||
|  |                 .map((e) => SizedBox(height: kButtonHeight, child: e)) | ||||||
|  |                 .toList(), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // 3. Share, copy link, and report | ||||||
|  |     final shareButtons = <Widget>[ | ||||||
|  |       FilledButton.tonal( | ||||||
|  |         onPressed: () { | ||||||
|  |           showShareSheetLink( | ||||||
|  |             context: context, | ||||||
|  |             link: 'https://solian.app/posts/${post.id}', | ||||||
|  |             title: 'sharePost'.tr(), | ||||||
|  |             toSystem: true, | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |         style: FilledButton.styleFrom( | ||||||
|  |           shape: const RoundedRectangleBorder( | ||||||
|  |             borderRadius: BorderRadius.only( | ||||||
|  |               topLeft: Radius.circular(kButtonRadius), | ||||||
|  |               bottomLeft: Radius.circular(kButtonRadius), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         child: Row( | ||||||
|  |           mainAxisSize: MainAxisSize.min, | ||||||
|  |           children: [ | ||||||
|  |             const Icon(Symbols.share, size: 18), | ||||||
|  |             const Gap(4), | ||||||
|  |             Text('share'.tr()), | ||||||
|  |           ], | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |     ]; | ||||||
|  |  | ||||||
|  |     if (!kIsWeb) { | ||||||
|  |       shareButtons.add( | ||||||
|  |         Tooltip( | ||||||
|  |           message: 'sharePostPhoto'.tr(), | ||||||
|  |           child: FilledButton.tonal( | ||||||
|  |             onPressed: () => sharePostAsScreenshot(context, ref, post), | ||||||
|  |             style: FilledButton.styleFrom( | ||||||
|  |               shape: const RoundedRectangleBorder( | ||||||
|  |                 borderRadius: BorderRadius.only( | ||||||
|  |                   topRight: Radius.circular(kButtonRadius), | ||||||
|  |                   bottomRight: Radius.circular(kButtonRadius), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |             child: const Icon(Symbols.share_reviews, size: 18), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     actions.add( | ||||||
|  |       Row( | ||||||
|  |         mainAxisSize: MainAxisSize.min, | ||||||
|  |         children: | ||||||
|  |             shareButtons | ||||||
|  |                 .map((e) => SizedBox(height: kButtonHeight, child: e)) | ||||||
|  |                 .toList(), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     actions.add( | ||||||
|  |       FilledButton.tonalIcon( | ||||||
|  |         onPressed: () { | ||||||
|  |           Clipboard.setData( | ||||||
|  |             ClipboardData(text: 'https://solian.app/posts/${post.id}'), | ||||||
|  |           ); | ||||||
|  |         }, | ||||||
|  |         icon: const Icon(Symbols.link), | ||||||
|  |         label: Text('copyLink'.tr()), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     actions.add( | ||||||
|  |       FilledButton.tonalIcon( | ||||||
|  |         onPressed: () { | ||||||
|  |           showAbuseReportSheet(context, resourceIdentifier: 'post/${post.id}'); | ||||||
|  |         }, | ||||||
|  |         icon: const Icon(Symbols.flag), | ||||||
|  |         label: Text('abuseReport'.tr()), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // Add gaps between actions (excluding first one) using FP style | ||||||
|  |     final children = | ||||||
|  |         actions.asMap().entries.expand((entry) { | ||||||
|  |           final index = entry.key; | ||||||
|  |           final action = entry.value; | ||||||
|  |           if (index == 0) { | ||||||
|  |             return [action]; | ||||||
|  |           } else { | ||||||
|  |             return [const Gap(8), action]; | ||||||
|  |           } | ||||||
|  |         }).toList(); | ||||||
|  |  | ||||||
|  |     return Container( | ||||||
|  |       height: kButtonHeight, | ||||||
|  |       margin: const EdgeInsets.only(bottom: 12), | ||||||
|  |       child: ListView( | ||||||
|  |         scrollDirection: Axis.horizontal, | ||||||
|  |         padding: const EdgeInsets.symmetric(horizontal: 8), | ||||||
|  |         children: children, | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| class PostDetailScreen extends HookConsumerWidget { | class PostDetailScreen extends HookConsumerWidget { | ||||||
|   final String id; |   final String id; | ||||||
|   const PostDetailScreen({super.key, required this.id}); |   const PostDetailScreen({super.key, required this.id}); | ||||||
| @@ -93,6 +412,25 @@ class PostDetailScreen extends HookConsumerWidget { | |||||||
|                         ), |                         ), | ||||||
|                       ), |                       ), | ||||||
|                     ), |                     ), | ||||||
|  |                     SliverToBoxAdapter( | ||||||
|  |                       child: Center( | ||||||
|  |                         child: ConstrainedBox( | ||||||
|  |                           constraints: BoxConstraints(maxWidth: 600), | ||||||
|  |                           child: PostActionButtons( | ||||||
|  |                             post: post, | ||||||
|  |                             onRefresh: () { | ||||||
|  |                               ref.invalidate(postProvider(id)); | ||||||
|  |                               ref.invalidate(postRepliesNotifierProvider(id)); | ||||||
|  |                             }, | ||||||
|  |                             onUpdate: (newItem) { | ||||||
|  |                               ref | ||||||
|  |                                   .read(postStateProvider(id).notifier) | ||||||
|  |                                   .updatePost(newItem); | ||||||
|  |                             }, | ||||||
|  |                           ), | ||||||
|  |                         ), | ||||||
|  |                       ), | ||||||
|  |                     ), | ||||||
|                     PostRepliesList(postId: id, maxWidth: 600), |                     PostRepliesList(postId: id, maxWidth: 600), | ||||||
|                     SliverGap(MediaQuery.of(context).padding.bottom + 80), |                     SliverGap(MediaQuery.of(context).padding.bottom + 80), | ||||||
|                   ], |                   ], | ||||||
|   | |||||||
							
								
								
									
										62
									
								
								lib/utils/share_utils.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								lib/utils/share_utils.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,62 @@ | |||||||
|  | import 'dart:io'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/foundation.dart'; | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
|  | import 'package:island/models/post.dart'; | ||||||
|  | import 'package:island/pods/config.dart'; | ||||||
|  | import 'package:island/widgets/alert.dart'; | ||||||
|  | import 'package:island/widgets/post/post_item_screenshot.dart'; | ||||||
|  | import 'package:path_provider/path_provider.dart' show getTemporaryDirectory; | ||||||
|  | import 'package:screenshot/screenshot.dart'; | ||||||
|  | import 'package:share_plus/share_plus.dart'; | ||||||
|  |  | ||||||
|  | /// Shares a post as a screenshot image | ||||||
|  | Future<void> sharePostAsScreenshot( | ||||||
|  |   BuildContext context, | ||||||
|  |   WidgetRef ref, | ||||||
|  |   SnPost post, | ||||||
|  | ) async { | ||||||
|  |   if (kIsWeb) return; | ||||||
|  |  | ||||||
|  |   final screenshotController = ScreenshotController(); | ||||||
|  |  | ||||||
|  |   showLoadingModal(context); | ||||||
|  |   await screenshotController | ||||||
|  |       .captureFromWidget( | ||||||
|  |         ProviderScope( | ||||||
|  |           overrides: [ | ||||||
|  |             sharedPreferencesProvider.overrideWithValue( | ||||||
|  |               ref.watch(sharedPreferencesProvider), | ||||||
|  |             ), | ||||||
|  |           ], | ||||||
|  |           child: Directionality( | ||||||
|  |             textDirection: TextDirection.ltr, | ||||||
|  |             child: SizedBox( | ||||||
|  |               width: 520, | ||||||
|  |               child: PostItemScreenshot(item: post, isFullPost: true), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |         context: context, | ||||||
|  |         pixelRatio: MediaQuery.of(context).devicePixelRatio, | ||||||
|  |         delay: const Duration(seconds: 1), | ||||||
|  |       ) | ||||||
|  |       .then((Uint8List? image) async { | ||||||
|  |         if (image == null) return; | ||||||
|  |         final directory = await getTemporaryDirectory(); | ||||||
|  |         final imagePath = await File('${directory.path}/image.png').create(); | ||||||
|  |         await imagePath.writeAsBytes(image); | ||||||
|  |  | ||||||
|  |         if (!context.mounted) return; | ||||||
|  |         hideLoadingModal(context); | ||||||
|  |         final box = context.findRenderObject() as RenderBox?; | ||||||
|  |         await Share.shareXFiles([ | ||||||
|  |           XFile(imagePath.path), | ||||||
|  |         ], sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size); | ||||||
|  |       }) | ||||||
|  |       .catchError((err) { | ||||||
|  |         if (context.mounted) hideLoadingModal(context); | ||||||
|  |         showErrorAlert(err); | ||||||
|  |       }); | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user