♻️ Refactored the post compose sheet

This commit is contained in:
2025-10-28 01:10:18 +08:00
parent 5f4cdf7937
commit a7990f83db
9 changed files with 501 additions and 109 deletions

View File

@@ -8,7 +8,7 @@ import 'package:island/screens/notification.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_dialog.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
enum FabMenuType { main, chat, realm }
@@ -166,7 +166,7 @@ class FabMenu extends HookConsumerWidget {
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeDialog.show(context);
await PostComposeSheet.show(context);
},
),
const Divider(),

View File

@@ -36,7 +36,8 @@ class PostComposeCard extends HookConsumerWidget {
final VoidCallback? onCancel;
final Function()? onSubmit;
final Function(ComposeState)? onStateChanged;
final bool isDialog;
final bool isContained;
final bool showHeader;
const PostComposeCard({
super.key,
@@ -45,7 +46,8 @@ class PostComposeCard extends HookConsumerWidget {
this.onCancel,
this.onSubmit,
this.onStateChanged,
this.isDialog = false,
this.isContained = false,
this.showHeader = true,
});
@override
@@ -169,14 +171,12 @@ class PostComposeCard extends HookConsumerWidget {
);
}
final maxHeight = math.min(
640.0,
MediaQuery.of(context).size.height * (isDialog ? 0.8 : 0.72),
);
final maxHeight = math.min(640.0, MediaQuery.of(context).size.height * 0.8);
return Card(
margin: EdgeInsets.zero,
color: isDialog ? Theme.of(context).colorScheme.surfaceContainer : null,
color: isContained ? Colors.transparent : null,
elevation: isContained ? 0 : null,
child: Container(
constraints: BoxConstraints(maxHeight: maxHeight),
child: Column(
@@ -184,75 +184,81 @@ class PostComposeCard extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header with actions
Container(
height: 65,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
if (showHeader)
Container(
height: 65,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: theme.colorScheme.outline.withOpacity(0.2),
),
),
),
),
child: Row(
children: [
const Gap(4),
Text(
'postCompose'.tr(),
style: theme.textTheme.titleMedium!.copyWith(fontSize: 18),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
child: Row(
children: [
const Gap(4),
Text(
'postCompose'.tr(),
style: theme.textTheme.titleMedium!.copyWith(
fontSize: 18,
),
),
),
IconButton(
onPressed:
(state.submitting.value ||
state.currentPublisher.value == null)
? null
: performSubmit,
icon:
state.submitting.value
? SizedBox(
width: 24,
height: 24,
child: const CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
originalPost != null
? Symbols.edit
: Symbols.upload,
),
tooltip:
originalPost != null
? 'postUpdate'.tr()
: 'postPublish'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
if (onCancel != null)
const Spacer(),
IconButton(
icon: const Icon(Symbols.close),
onPressed: onCancel,
tooltip: 'cancel'.tr(),
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
],
IconButton(
onPressed:
(state.submitting.value ||
state.currentPublisher.value == null)
? null
: performSubmit,
icon:
state.submitting.value
? SizedBox(
width: 24,
height: 24,
child: const CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
originalPost != null
? Symbols.edit
: Symbols.upload,
),
tooltip:
originalPost != null
? 'postUpdate'.tr()
: 'postPublish'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
if (onCancel != null)
IconButton(
icon: const Icon(Symbols.close),
onPressed: onCancel,
tooltip: 'cancel'.tr(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
],
),
),
),
// Info banner (reply/forward)
ComposeInfoBanner(
@@ -310,7 +316,7 @@ class PostComposeCard extends HookConsumerWidget {
onTap: () {
if (state.currentPublisher.value == null) {
// No publisher loaded, guide user to create one
if (isDialog) {
if (isContained) {
Navigator.of(context).pop();
}
context.pushNamed('creatorNew').then((value) {
@@ -347,7 +353,7 @@ class PostComposeCard extends HookConsumerWidget {
onPublisherTap: () {
if (state.currentPublisher.value == null) {
// No publisher loaded, guide user to create one
if (isDialog) {
if (isContained) {
Navigator.of(context).pop();
}
context.pushNamed('creatorNew').then((

View File

@@ -7,8 +7,13 @@ import 'package:island/models/post.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:material_symbols_icons/symbols.dart';
/// A dialog that wraps PostComposeCard for easy use in dialogs.
/// This provides a convenient way to show the compose interface in a modal dialog.
@@ -29,16 +34,15 @@ class PostComposeDialog extends HookConsumerWidget {
SnPost? originalPost,
PostComposeInitialState? initialState,
}) {
return showDialog<bool>(
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => Padding(
padding: EdgeInsets.all(16),
child: PostComposeDialog(
originalPost: originalPost,
initialState: initialState,
),
(context) => PostComposeDialog(
originalPost: originalPost,
initialState: initialState,
isBottomSheet: true,
),
);
}
@@ -48,7 +52,41 @@ class PostComposeDialog extends HookConsumerWidget {
final drafts = ref.watch(composeStorageNotifierProvider);
final restoredInitialState = useState<PostComposeInitialState?>(null);
final prompted = useState(false);
final isWide = isWideScreen(context);
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// Create compose state
final state = useMemoized(
() => ComposeLogic.createState(
originalPost: originalPost,
forwardedPost: forwardedPost,
repliedPost: repliedPost,
postType: 0,
),
[originalPost, forwardedPost, repliedPost],
);
// Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized(
() => Listenable.merge([
state.titleController,
state.descriptionController,
state.contentController,
state.visibility,
state.attachments,
state.attachmentProgress,
state.currentPublisher,
state.submitting,
]),
[state],
);
useListenable(stateNotifier);
// Use shared state management utilities
ComposeStateUtils.usePublisherInitialization(ref, state);
ComposeStateUtils.useInitialStateLoader(state, initialState);
useEffect(() {
if (!prompted.value &&
@@ -64,24 +102,69 @@ class PostComposeDialog extends HookConsumerWidget {
return null;
}, [drafts, prompted.value]);
return Dialog(
insetPadding: isWide ? const EdgeInsets.all(16) : EdgeInsets.zero,
child: ConstrainedBox(
constraints:
isWide
? const BoxConstraints(maxWidth: 600)
: const BoxConstraints.expand(),
child: PostComposeCard(
originalPost: originalPost,
initialState: restoredInitialState.value ?? initialState,
onCancel: () => Navigator.of(context).pop(),
onSubmit: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
isDialog: true,
),
// Helper methods for actions
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
);
}
final actions = [
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
),
IconButton(
onPressed:
(state.submitting.value || state.currentPublisher.value == null)
? null
: performSubmit,
icon:
state.submitting.value
? SizedBox(
width: 24,
height: 24,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: Icon(originalPost != null ? Symbols.edit : Symbols.upload),
tooltip: originalPost != null ? 'postUpdate'.tr() : 'postPublish'.tr(),
),
];
return SheetScaffold(
titleText: 'postCompose'.tr(),
actions: actions,
child: PostComposeCard(
originalPost: originalPost,
initialState: restoredInitialState.value ?? initialState,
onCancel: () => Navigator.of(context).pop(),
onSubmit: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
isContained: true,
showHeader: false,
),
);
}

View File

@@ -157,6 +157,8 @@ class ComposeSettingsSheet extends HookConsumerWidget {
);
}
final tagInputController = useTextEditingController();
return SheetScaffold(
titleText: 'postSettings'.tr(),
heightFactor: 0.6,
@@ -255,6 +257,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
),
// Tag input with autocomplete
TypeAheadField<SnPostTag>(
controller: tagInputController,
builder: (context, controller, focusNode) {
return TextField(
controller: controller,
@@ -279,6 +282,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
borderRadius: BorderRadius.all(Radius.circular(8)),
),
title: Text('#${suggestion.slug}'),
subtitle: Text('${suggestion.usage} posts'),
dense: true,
);
},
@@ -289,6 +293,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
suggestion.slug,
];
}
tagInputController.clear();
},
direction: VerticalDirection.down,
hideOnEmpty: true,

View File

@@ -0,0 +1,300 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:material_symbols_icons/symbols.dart';
/// A dialog that wraps PostComposeCard for easy use in dialogs.
/// This provides a convenient way to show the compose interface in a modal dialog.
class PostComposeSheet extends HookConsumerWidget {
final SnPost? originalPost;
final PostComposeInitialState? initialState;
final bool isBottomSheet;
const PostComposeSheet({
super.key,
this.originalPost,
this.initialState,
this.isBottomSheet = false,
});
static Future<bool?> show(
BuildContext context, {
SnPost? originalPost,
PostComposeInitialState? initialState,
}) {
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => PostComposeSheet(
originalPost: originalPost,
initialState: initialState,
isBottomSheet: true,
),
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final drafts = ref.watch(composeStorageNotifierProvider);
final restoredInitialState = useState<PostComposeInitialState?>(null);
final prompted = useState(false);
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// Create compose state
final state = useMemoized(
() => ComposeLogic.createState(
originalPost: originalPost,
forwardedPost: forwardedPost,
repliedPost: repliedPost,
postType: 0,
),
[originalPost, forwardedPost, repliedPost],
);
// Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized(
() => Listenable.merge([
state.titleController,
state.descriptionController,
state.contentController,
state.visibility,
state.attachments,
state.attachmentProgress,
state.currentPublisher,
state.submitting,
]),
[state],
);
useListenable(stateNotifier);
// Use shared state management utilities
ComposeStateUtils.usePublisherInitialization(ref, state);
ComposeStateUtils.useInitialStateLoader(state, initialState);
useEffect(() {
if (!prompted.value &&
originalPost == null &&
initialState?.replyingTo == null &&
initialState?.forwardingTo == null &&
drafts.isNotEmpty) {
prompted.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showRestoreDialog(ref, restoredInitialState);
});
}
return null;
}, [drafts, prompted.value]);
// Helper methods for actions
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
);
}
final actions = [
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
),
IconButton(
onPressed:
(state.submitting.value || state.currentPublisher.value == null)
? null
: performSubmit,
icon:
state.submitting.value
? SizedBox(
width: 24,
height: 24,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: Icon(originalPost != null ? Symbols.edit : Symbols.upload),
tooltip: originalPost != null ? 'postUpdate'.tr() : 'postPublish'.tr(),
),
];
return SheetScaffold(
titleText: 'postCompose'.tr(),
actions: actions,
child: PostComposeCard(
originalPost: originalPost,
initialState: restoredInitialState.value ?? initialState,
onCancel: () => Navigator.of(context).pop(),
onSubmit: () {
// Fire event to notify listeners that a post was created
eventBus.fire(PostCreatedEvent());
Navigator.of(context).pop(true);
},
isContained: true,
showHeader: false,
),
);
}
Future<void> _showRestoreDialog(
WidgetRef ref,
ValueNotifier<PostComposeInitialState?> restoredInitialState,
) async {
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final latestDraft = drafts.values.last;
final restore = await showDialog<bool>(
context: ref.context,
useRootNavigator: true,
builder:
(context) => AlertDialog(
title: Text('restoreDraftTitle'.tr()),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('restoreDraftMessage'.tr()),
const SizedBox(height: 16),
_buildCompactDraftPreview(context, latestDraft),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('no'.tr()),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('yes'.tr()),
),
],
),
);
if (restore == true) {
// Delete the old draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(latestDraft.id);
restoredInitialState.value = PostComposeInitialState(
title: latestDraft.title,
description: latestDraft.description,
content: latestDraft.content,
visibility: latestDraft.visibility,
attachments:
latestDraft.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList(),
);
}
}
}
Widget _buildCompactDraftPreview(BuildContext context, SnPost draft) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.description,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'draft'.tr(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
if (draft.title?.isNotEmpty ?? false)
Text(
draft.title!,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (draft.content?.isNotEmpty ?? false)
Text(
draft.content!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (draft.attachments.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.attach_file,
size: 12,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
'${draft.attachments.length} attachment${draft.attachments.length > 1 ? 's' : ''}',
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 11,
),
),
],
),
],
),
);
}
}

View File

@@ -178,10 +178,9 @@ class ComposeToolbar extends HookConsumerWidget {
horizontal: -4,
vertical: -4,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
minHeight: 48,
),
)
else if (originalPost == null)
@@ -195,10 +194,9 @@ class ComposeToolbar extends HookConsumerWidget {
horizontal: -4,
vertical: -4,
),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
minHeight: 48,
),
),
],

View File

@@ -26,7 +26,7 @@ import 'package:island/widgets/post/embed_view_renderer.dart';
import 'package:island/widgets/post/post_reaction_sheet.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:island/widgets/share/share_sheet.dart';
import 'package:island/widgets/post/compose_dialog.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart';
@@ -180,7 +180,7 @@ class PostActionableItem extends HookConsumerWidget {
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () async {
final result = await PostComposeDialog.show(
final result = await PostComposeSheet.show(
context,
originalPost: item,
);
@@ -227,7 +227,7 @@ class PostActionableItem extends HookConsumerWidget {
title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply),
callback: () async {
final result = await PostComposeDialog.show(
final result = await PostComposeSheet.show(
context,
initialState: PostComposeInitialState(replyingTo: item),
);
@@ -240,7 +240,7 @@ class PostActionableItem extends HookConsumerWidget {
title: 'forward'.tr(),
image: MenuImage.icon(Symbols.forward),
callback: () async {
final result = await PostComposeDialog.show(
final result = await PostComposeSheet.show(
context,
initialState: PostComposeInitialState(forwardingTo: item),
);

View File

@@ -10,7 +10,7 @@ import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_dialog.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -124,7 +124,7 @@ class PostQuickReply extends HookConsumerWidget {
IconButton(
onPressed: () async {
onLaunch?.call();
final value = await PostComposeDialog.show(
final value = await PostComposeSheet.show(
context,
initialState: PostComposeInitialState(
content: contentController.text,

View File

@@ -5,7 +5,7 @@ import 'package:gap/gap.dart';
import 'package:island/models/thought.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_dialog.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/widgets/thought/function_calls_section.dart';
import 'package:island/widgets/thought/proposals_section.dart';
import 'package:island/widgets/thought/reasoning_section.dart';
@@ -29,7 +29,7 @@ void _handleProposalAction(BuildContext context, Map<String, String> proposal) {
switch (proposal['type']) {
case 'post_create':
// Show post creation dialog with the proposal content
PostComposeDialog.show(
PostComposeSheet.show(
context,
initialState: PostComposeInitialState(
content: (proposal['content'] ?? '').trim(),