diff --git a/lib/screens/posts/post_detail.dart b/lib/screens/posts/post_detail.dart index 95b1a199..6e60cb66 100644 --- a/lib/screens/posts/post_detail.dart +++ b/lib/screens/posts/post_detail.dart @@ -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> { } } +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 = []; + + 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 = [ + 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 = [ + 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 = [ + 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), ], diff --git a/lib/utils/share_utils.dart b/lib/utils/share_utils.dart new file mode 100644 index 00000000..b3354f70 --- /dev/null +++ b/lib/utils/share_utils.dart @@ -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 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); + }); +}