✨ Post actions on post detail page
This commit is contained in:
		| @@ -1,16 +1,26 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/models/post.dart'; | ||||
| import 'package:island/pods/network.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/extended_refresh_indicator.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_replies.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: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 { | ||||
|   final String 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), | ||||
|                     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