✨ 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