♻️ Refactor post

This commit is contained in:
2025-10-06 11:55:53 +08:00
parent a8a59ee30c
commit f871cd3b62
7 changed files with 1109 additions and 643 deletions

View File

@@ -77,16 +77,32 @@ class ChatInput extends HookConsumerWidget {
} }
Future<void> handlePaste() async { Future<void> handlePaste() async {
final clipboard = await Pasteboard.image; final image = await Pasteboard.image;
if (clipboard == null) return; if (image != null) {
onAttachmentsChanged([
...attachments,
UniversalFile(
data: XFile.fromData(image, mimeType: "image/jpeg"),
type: UniversalFileType.image,
),
]);
return;
}
onAttachmentsChanged([ final textData = await Clipboard.getData(Clipboard.kTextPlain);
...attachments, if (textData != null && textData.text != null) {
UniversalFile( final text = messageController.text;
data: XFile.fromData(clipboard, mimeType: "image/jpeg"), final selection = messageController.selection;
type: UniversalFileType.image, final start = selection.start >= 0 ? selection.start : text.length;
), final end = selection.end >= 0 ? selection.end : text.length;
]); final newText = text.replaceRange(start, end, textData.text!);
messageController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + textData.text!.length,
),
);
}
} }
inputFocusNode.onKeyEvent = (node, event) { inputFocusNode.onKeyEvent = (node, event) {

View File

@@ -0,0 +1,202 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/attachment_uploader.dart';
import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/post/compose_shared.dart';
/// A reusable widget for displaying attachments in compose screens.
/// Supports both grid and list layouts based on screen width.
class ComposeAttachments extends ConsumerWidget {
final ComposeState state;
final bool isCompact;
const ComposeAttachments({
super.key,
required this.state,
this.isCompact = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (state.attachments.value.isEmpty) {
return const SizedBox.shrink();
}
return LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
return isWide ? _buildWideGrid(ref) : _buildNarrowList(ref);
},
);
}
Widget _buildWideGrid(WidgetRef ref) {
return GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: state.attachments.value.length,
itemBuilder: (context, idx) {
return _buildAttachmentItem(ref, idx, isCompact: true);
},
);
}
Widget _buildNarrowList(WidgetRef ref) {
return Column(
children: [
for (var idx = 0; idx < state.attachments.value.length; idx++)
Container(
margin: const EdgeInsets.only(bottom: 8),
child: _buildAttachmentItem(ref, idx, isCompact: false),
),
],
);
}
Widget _buildAttachmentItem(
WidgetRef ref,
int idx, {
required bool isCompact,
}) {
final progressMap = state.attachmentProgress.value;
return AttachmentPreview(
isCompact: isCompact,
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload: () async {
final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: ref.context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) =>
AttachmentUploaderSheet(ref: ref, state: state, index: idx),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate: (value) => ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
idx,
delta,
);
},
);
}
}
/// A specialized attachment widget for article compose with expansion tile.
class ArticleComposeAttachments extends ConsumerWidget {
final ComposeState state;
const ArticleComposeAttachments({super.key, required this.state});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ValueListenableBuilder<List<UniversalFile>>(
valueListenable: state.attachments,
builder: (context, attachments, _) {
if (attachments.isEmpty) return const SizedBox.shrink();
return Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: true,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('attachments'),
Text(
'articleAttachmentHint',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
children: [
ValueListenableBuilder<Map<int, double>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
return Wrap(
runSpacing: 8,
spacing: 8,
children: [
for (var idx = 0; idx < attachments.length; idx++)
SizedBox(
width: 180,
height: 180,
child: AttachmentPreview(
isCompact: true,
item: attachments[idx],
progress: progressMap[idx],
onRequestUpload: () async {
final config = await showModalBottomSheet<
AttachmentUploadConfig
>(
context: context,
isScrollControlled: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onUpdate:
(value) => ComposeLogic.updateAttachment(
state,
value,
idx,
),
onDelete:
() => ComposeLogic.deleteAttachment(
ref,
state,
idx,
),
onInsert:
() => ComposeLogic.insertAttachment(
ref,
state,
idx,
),
),
),
],
);
},
),
const SizedBox(height: 16),
],
),
);
},
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -8,19 +7,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/creators/publishers_form.dart'; import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/attachment_uploader.dart';
import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_attachments.dart';
import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_info_banner.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.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:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/widgets/post/publishers_modal.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -54,7 +54,6 @@ class PostComposeCard extends HookConsumerWidget {
initialState?.forwardingTo ?? originalPost?.forwardedPost; initialState?.forwardingTo ?? originalPost?.forwardedPost;
final theme = Theme.of(context); final theme = Theme.of(context);
final publishers = ref.watch(publishersManagedProvider);
// Capture the notifier to avoid using ref after dispose // Capture the notifier to avoid using ref after dispose
final notifier = ref.read(composeStorageNotifierProvider.notifier); final notifier = ref.read(composeStorageNotifierProvider.notifier);
@@ -92,31 +91,9 @@ class PostComposeCard extends HookConsumerWidget {
return null; return null;
}, [state]); }, [state]);
// Initialize publisher once when data is available // Use shared state management utilities
useEffect(() { ComposeStateUtils.usePublisherInitialization(ref, state);
if (publishers.value?.isNotEmpty ?? false) { ComposeStateUtils.useInitialStateLoader(state, initialState);
if (state.currentPublisher.value == null) {
state.currentPublisher.value = publishers.value!.first;
}
}
return null;
}, [publishers]);
// Load initial state if provided
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState!.title ?? '';
state.descriptionController.text = initialState!.description ?? '';
state.contentController.text = initialState!.content ?? '';
if (initialState!.visibility != null) {
state.visibility.value = initialState!.visibility!;
}
if (initialState!.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState!.attachments);
}
}
return null;
}, [initialState]);
// Dispose state when widget is disposed // Dispose state when widget is disposed
useEffect(() { useEffect(() {
@@ -154,43 +131,6 @@ class PostComposeCard extends HookConsumerWidget {
}; };
}, []); }, []);
// Reset form to clean state for new composition
void resetForm() {
// Clear text fields
state.titleController.clear();
state.descriptionController.clear();
state.contentController.clear();
state.slugController.clear();
// Reset visibility to default (0 = public)
state.visibility.value = 0;
// Clear attachments
state.attachments.value = [];
// Clear attachment progress
state.attachmentProgress.value = {};
// Clear tags
state.tagsController.clearTags();
// Clear categories
state.categories.value = [];
// Clear embed view
state.embedView.value = null;
// Clear poll
state.pollId.value = null;
// Clear realm
state.realm.value = null;
// Generate new draft ID for fresh composition
// Note: We don't recreate the entire state, just reset the fields
// The existing state object is reused for continuity
}
// Helper methods // Helper methods
void showSettingsSheet() { void showSettingsSheet() {
showModalBottomSheet( showModalBottomSheet(
@@ -202,211 +142,28 @@ class PostComposeCard extends HookConsumerWidget {
} }
Future<void> performSubmit() async { Future<void> performSubmit() async {
if (state.submitting.value) return; await ComposeSubmitUtils.performSubmit(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () {
// Mark as submitted
submitted.value = true;
// Don't submit empty posts (no content and no attachments) // Delete draft after successful submission
final hasContent = ref
state.titleController.text.trim().isNotEmpty || .read(composeStorageNotifierProvider.notifier)
state.descriptionController.text.trim().isNotEmpty || .deleteDraft(state.draftId);
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) { // Reset the form for new composition
// Show error message if context is mounted ComposeStateUtils.resetForm(state);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('postContentEmpty'.tr())));
}
return; // Don't submit empty posts
}
try {
state.submitting.value = true;
// Upload any local attachments first
await Future.wait(
state.attachments.value
.asMap()
.entries
.where((entry) => entry.value.isOnDevice)
.map(
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
),
);
// Prepare API request
final client = ref.read(apiClientProvider);
final isNewPost = originalPost == null;
final endpoint =
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost!.id}'}';
// Create request payload
final payload = {
'title': state.titleController.text,
'description': state.descriptionController.text,
'content': state.contentController.text,
if (state.slugController.text.isNotEmpty)
'slug': state.slugController.text,
'visibility': state.visibility.value,
'attachments':
state.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data.id)
.toList(),
'type': state.postType,
if (repliedPost != null) 'replied_post_id': repliedPost.id,
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
'tags': state.tagsController.getTags,
'categories': state.categories.value.map((e) => e.slug).toList(),
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
if (state.pollId.value != null) 'poll_id': state.pollId.value,
if (state.embedView.value != null)
'embed_view': state.embedView.value!.toJson(),
};
// Send request
final response = await client.request(
endpoint,
queryParameters: {'pub': state.currentPublisher.value?.name},
data: payload,
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
);
// Create the post object from the response for the callback
final post = SnPost.fromJson(response.data);
// Mark as submitted
submitted.value = true;
// Delete draft after successful submission
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
// Reset the form for new composition
resetForm();
// Call the success callback with the created/updated post
onSubmit?.call(post);
} catch (err) {
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
rethrow;
} finally {
state.submitting.value = false;
}
}
Widget buildWideAttachmentGrid() {
return GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: state.attachments.value.length,
itemBuilder: (context, idx) {
final progressMap = state.attachmentProgress.value;
return AttachmentPreview(
isCompact: true,
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload: () async {
final config = await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) => ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
idx,
delta,
);
},
);
}, },
); );
} }
Widget buildNarrowAttachmentList() {
return Column(
children: [
for (var idx = 0; idx < state.attachments.value.length; idx++)
Container(
margin: const EdgeInsets.only(bottom: 8),
child: () {
final progressMap = state.attachmentProgress.value;
return AttachmentPreview(
item: state.attachments.value[idx],
progress: progressMap[idx],
onRequestUpload: () async {
final config =
await showModalBottomSheet<AttachmentUploadConfig>(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => AttachmentUploaderSheet(
ref: ref,
state: state,
index: idx,
),
);
if (config != null) {
await ComposeLogic.uploadAttachment(
ref,
state,
idx,
poolId: config.poolId,
);
}
},
onDelete:
() => ComposeLogic.deleteAttachment(ref, state, idx),
onUpdate:
(value) =>
ComposeLogic.updateAttachment(state, value, idx),
onMove: (delta) {
state.attachments.value = ComposeLogic.moveAttachment(
state.attachments.value,
idx,
delta,
);
},
);
}(),
),
],
);
}
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
@@ -489,7 +246,58 @@ class PostComposeCard extends HookConsumerWidget {
), ),
// Info banner (reply/forward) // Info banner (reply/forward)
_buildInfoBanner(context), ComposeInfoBanner(
originalPost: originalPost,
replyingTo: repliedPost,
forwardingTo: forwardedPost,
onReferencePostTap: (context, post) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
backgroundColor: Colors.transparent,
builder:
(context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder:
(context, scrollController) => Container(
decoration: BoxDecoration(
color:
Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: PostItem(item: post),
),
),
],
),
),
),
);
},
),
// Main content area // Main content area
Expanded( Expanded(
@@ -557,114 +365,44 @@ class PostComposeCard extends HookConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (state.currentPublisher.value == null) ComposeFormFields(
Container( state: state,
padding: const EdgeInsets.all(12), showPublisherAvatar: false,
margin: const EdgeInsets.only(bottom: 8), onPublisherTap: () {
decoration: BoxDecoration( if (state.currentPublisher.value == null) {
color: // No publisher loaded, guide user to create one
theme.colorScheme.surfaceContainerHigh, if (isInDialog) {
borderRadius: BorderRadius.circular(8), Navigator.of(context).pop();
), }
child: Row( context.pushNamed('creatorNew').then((
crossAxisAlignment: value,
CrossAxisAlignment.start, ) {
children: [ if (value != null) {
Icon( state.currentPublisher.value =
Symbols.info, value as SnPublisher;
size: 16, ref.invalidate(
color: theme.colorScheme.primary, publishersManagedProvider,
), );
const Gap(8), }
Expanded( });
child: Text( } else {
'Tap the avatar to create a publisher and start composing.', // Show modal to select from existing publishers
style: theme.textTheme.bodySmall showModalBottomSheet(
?.copyWith( isScrollControlled: true,
color: useRootNavigator: true,
theme context: context,
.colorScheme builder:
.onSurfaceVariant, (context) => const PublisherModal(),
), ).then((value) {
), if (value != null) {
), state.currentPublisher.value = value;
], }
), });
), }
TextField( },
controller: state.titleController,
enabled: state.currentPublisher.value != null,
decoration: InputDecoration(
hintText: 'postTitle'.tr(),
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
),
style: theme.textTheme.titleMedium,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
), ),
TextField(
controller: state.descriptionController,
enabled: state.currentPublisher.value != null,
decoration: InputDecoration(
hintText: 'postDescription'.tr(),
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.fromLTRB(
8,
4,
8,
12,
),
),
style: theme.textTheme.bodyMedium,
minLines: 1,
maxLines: 3,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
TextField(
controller: state.contentController,
enabled: state.currentPublisher.value != null,
style: theme.textTheme.bodyMedium,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postContent'.tr(),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
),
maxLines: null,
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
),
const Gap(8), const Gap(8),
ComposeAttachments(state: state, isCompact: true),
// Attachments preview
if (state.attachments.value.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
final isWide = isWideScreen(context);
return isWide
? buildWideAttachmentGrid()
: buildNarrowAttachmentList();
},
)
else
const SizedBox.shrink(),
], ],
), ),
), ),
@@ -695,262 +433,4 @@ class PostComposeCard extends HookConsumerWidget {
), ),
); );
} }
Widget _buildInfoBanner(BuildContext context) {
final effectiveRepliedPost =
initialState?.replyingTo ?? originalPost?.repliedPost;
final effectiveForwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// Show editing banner when editing a post
if (originalPost != null) {
return Column(
children: [
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
Icon(
Symbols.edit,
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const Gap(8),
Text(
'postEditing'.tr(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
).padding(horizontal: 16, vertical: 8),
),
// Show reply/forward banners below editing banner if they exist
if (effectiveRepliedPost != null)
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.reply, size: 16),
const Gap(4),
Text(
'postReplyingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveRepliedPost),
],
).padding(all: 16),
),
if (effectiveForwardedPost != null)
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.forward, size: 16),
const Gap(4),
Text(
'postForwardingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveForwardedPost),
],
).padding(all: 16),
),
],
);
}
// Show banner for replies
if (effectiveRepliedPost != null) {
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.reply, size: 16),
const Gap(4),
Text(
'postReplyingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveRepliedPost),
],
).padding(all: 16),
);
}
// Show banner for forwards
if (effectiveForwardedPost != null) {
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Symbols.forward, size: 16),
const Gap(4),
Text(
'postForwardingTo'.tr(),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const Gap(8),
_buildCompactReferencePost(context, effectiveForwardedPost),
],
).padding(all: 16),
);
}
return const SizedBox.shrink();
}
Widget _buildCompactReferencePost(BuildContext context, SnPost post) {
return GestureDetector(
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
backgroundColor: Colors.transparent,
builder:
(context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder:
(context, scrollController) => Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: PostItem(item: post),
),
),
],
),
),
),
);
},
child: 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: Row(
children: [
ProfilePictureWidget(
fileId: post.publisher.picture?.id,
radius: 16,
),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.publisher.nick,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (post.title?.isNotEmpty ?? false)
Text(
post.title!,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (post.content?.isNotEmpty ?? false)
Text(
post.content!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (post.attachments.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.attach_file,
size: 12,
color: Theme.of(context).colorScheme.secondary,
),
const Gap(4),
Text(
'postHasAttachments'.plural(post.attachments.length),
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 11,
),
),
],
),
],
),
),
Icon(
Symbols.open_in_full,
size: 16,
color: Theme.of(context).colorScheme.outline,
),
],
),
),
);
}
} }

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_shared.dart';
/// A reusable widget for the form fields in compose screens.
/// Includes title, description, and content text fields.
class ComposeFormFields extends StatelessWidget {
final ComposeState state;
final bool enabled;
final bool showPublisherAvatar;
final VoidCallback? onPublisherTap;
const ComposeFormFields({
super.key,
required this.state,
this.enabled = true,
this.showPublisherAvatar = true,
this.onPublisherTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Publisher profile picture
if (showPublisherAvatar)
GestureDetector(
child: ProfilePictureWidget(
fileId: state.currentPublisher.value?.picture?.id,
radius: 20,
fallbackIcon:
state.currentPublisher.value == null
? Icons.question_mark
: null,
),
onTap: onPublisherTap,
),
// Post content form
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (state.currentPublisher.value == null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Tap the avatar to create a publisher and start composing.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
// Title field
TextField(
controller: state.titleController,
enabled: enabled && state.currentPublisher.value != null,
decoration: InputDecoration(
hintText: 'postTitle',
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
),
style: theme.textTheme.titleMedium,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
// Description field
TextField(
controller: state.descriptionController,
enabled: enabled && state.currentPublisher.value != null,
decoration: InputDecoration(
hintText: 'postDescription',
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
),
style: theme.textTheme.bodyMedium,
minLines: 1,
maxLines: 3,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
// Content field
TextField(
controller: state.contentController,
enabled: enabled && state.currentPublisher.value != null,
style: theme.textTheme.bodyMedium,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postContent',
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
),
maxLines: null,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
),
],
);
}
}
/// A specialized form fields widget for article compose with expanded content field.
class ArticleComposeFormFields extends StatelessWidget {
final ComposeState state;
final bool enabled;
const ArticleComposeFormFields({
super.key,
required this.state,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title field
TextField(
controller: state.titleController,
decoration: InputDecoration(
hintText: 'postTitle',
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 8,
),
),
style: theme.textTheme.titleMedium,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
// Description field
TextField(
controller: state.descriptionController,
decoration: InputDecoration(
hintText: 'postDescription',
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
),
style: theme.textTheme.bodyMedium,
minLines: 1,
maxLines: 3,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
// Content field (expanded)
Expanded(
child: TextField(
controller: state.contentController,
style: theme.textTheme.bodyMedium,
decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postContent',
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 8,
),
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,221 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
/// A reusable widget for displaying info banners in compose screens.
/// Shows editing, reply, and forward information.
class ComposeInfoBanner extends StatelessWidget {
final SnPost? originalPost;
final SnPost? replyingTo;
final SnPost? forwardingTo;
final Function(BuildContext, SnPost)? onReferencePostTap;
const ComposeInfoBanner({
super.key,
this.originalPost,
this.replyingTo,
this.forwardingTo,
this.onReferencePostTap,
});
@override
Widget build(BuildContext context) {
final effectiveRepliedPost = replyingTo ?? originalPost?.repliedPost;
final effectiveForwardedPost = forwardingTo ?? originalPost?.forwardedPost;
// Show editing banner when editing a post
if (originalPost != null) {
return Column(
children: [
Container(
width: double.infinity,
color: Theme.of(context).colorScheme.primaryContainer,
child: Row(
children: [
Icon(
Symbols.edit,
size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const Gap(8),
Text(
'postEditing',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
).padding(horizontal: 16, vertical: 8),
),
// Show reply/forward banners below editing banner if they exist
if (effectiveRepliedPost != null)
_buildReferenceBanner(
context,
effectiveRepliedPost,
Symbols.reply,
'postReplyingTo',
),
if (effectiveForwardedPost != null)
_buildReferenceBanner(
context,
effectiveForwardedPost,
Symbols.forward,
'postForwardingTo',
),
],
);
}
// Show banner for replies
if (effectiveRepliedPost != null) {
return _buildReferenceBanner(
context,
effectiveRepliedPost,
Symbols.reply,
'postReplyingTo',
);
}
// Show banner for forwards
if (effectiveForwardedPost != null) {
return _buildReferenceBanner(
context,
effectiveForwardedPost,
Symbols.forward,
'postForwardingTo',
);
}
return const SizedBox.shrink();
}
Widget _buildReferenceBanner(
BuildContext context,
SnPost post,
IconData icon,
String labelKey,
) {
return Container(
width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 16),
const Gap(4),
Text(labelKey, style: Theme.of(context).textTheme.labelMedium),
],
),
const Gap(8),
CompactReferencePost(
post: post,
onTap:
onReferencePostTap != null
? () => onReferencePostTap!(context, post)
: null,
),
],
).padding(all: 16),
);
}
}
/// A compact widget for displaying reference posts (replies/forwards).
class CompactReferencePost extends StatelessWidget {
final SnPost post;
final VoidCallback? onTap;
const CompactReferencePost({super.key, required this.post, this.onTap});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: theme.colorScheme.outline.withOpacity(0.3)),
),
child: Row(
children: [
ProfilePictureWidget(
fileId: post.publisher.picture?.id,
radius: 16,
),
const Gap(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.publisher.nick,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
if (post.title?.isNotEmpty ?? false)
Text(
post.title!,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 13,
color: theme.colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (post.content?.isNotEmpty ?? false)
Text(
post.content!,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (post.attachments.isNotEmpty)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.attach_file,
size: 12,
color: theme.colorScheme.secondary,
),
const Gap(4),
Text(
'postHasAttachments'.plural(post.attachments.length),
style: TextStyle(
color: theme.colorScheme.secondary,
fontSize: 11,
),
),
],
),
],
),
),
if (onTap != null)
Icon(
Symbols.open_in_full,
size: 16,
color: theme.colorScheme.outline,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,194 @@
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/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/post/compose_shared.dart';
/// Utility class for common compose state management logic.
class ComposeStateUtils {
/// Initializes publisher when data becomes available.
static void usePublisherInitialization(WidgetRef ref, ComposeState state) {
final publishers = ref.watch(publishersManagedProvider);
useEffect(() {
if (publishers.value?.isNotEmpty ?? false) {
if (state.currentPublisher.value == null) {
state.currentPublisher.value = publishers.value!.first;
}
}
return null;
}, [publishers]);
}
/// Loads initial state from provided parameters.
static void useInitialStateLoader(
ComposeState state,
PostComposeInitialState? initialState,
) {
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState.title ?? '';
state.descriptionController.text = initialState.description ?? '';
state.contentController.text = initialState.content ?? '';
if (initialState.visibility != null) {
state.visibility.value = initialState.visibility!;
}
if (initialState.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState.attachments);
}
}
return null;
}, [initialState]);
}
/// Loads draft if available (for new posts without initial state).
static void useDraftLoader(
WidgetRef ref,
ComposeState state,
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
PostComposeInitialState? initialState,
) {
useEffect(() {
if (originalPost == null &&
forwardedPost == null &&
repliedPost == null &&
initialState == null) {
// Try to load the most recent draft
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce(
(a, b) =>
(a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
? a
: b,
);
// Only load if the draft has meaningful content
if (mostRecentDraft.content?.isNotEmpty == true ||
mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text =
mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility;
}
}
}
return null;
}, []);
}
/// Handles auto-save functionality for new posts.
static void useAutoSave(WidgetRef ref, ComposeState state, bool isNewPost) {
useEffect(() {
if (isNewPost) {
state.startAutoSave(ref);
}
return () => state.stopAutoSave();
}, [state]);
}
/// Handles disposal and draft saving logic.
static void useDisposalHandler(
WidgetRef ref,
ComposeState state,
SnPost? originalPost,
bool submitted,
) {
useEffect(() {
return () {
if (!submitted &&
originalPost == null &&
state.currentPublisher.value != null) {
final hasContent =
state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty ||
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
if (hasContent || hasAttachments) {
final draft = SnPost(
id: state.draftId,
title: state.titleController.text,
description: state.descriptionController.text,
content: state.contentController.text,
visibility: state.visibility.value,
type: state.postType,
attachments:
state.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data as SnCloudFile)
.toList(),
publisher: state.currentPublisher.value!,
updatedAt: DateTime.now(),
);
ref
.read(composeStorageNotifierProvider.notifier)
.saveDraft(draft)
.catchError((e) => debugPrint('Failed to save draft: $e'));
}
}
ComposeLogic.dispose(state);
};
}, []);
}
/// Creates and manages the state notifier for rebuilds.
static Listenable useStateNotifier(ComposeState state) {
return useMemoized(
() => Listenable.merge([
state.titleController,
state.descriptionController,
state.contentController,
state.visibility,
state.attachments,
state.attachmentProgress,
state.currentPublisher,
state.submitting,
]),
[state],
);
}
/// Resets form to clean state for new composition.
static void resetForm(ComposeState state) {
// Clear text fields
state.titleController.clear();
state.descriptionController.clear();
state.contentController.clear();
state.slugController.clear();
// Reset visibility to default (0 = public)
state.visibility.value = 0;
// Clear attachments
state.attachments.value = [];
// Clear attachment progress
state.attachmentProgress.value = {};
// Clear tags
state.tagsController.clearTags();
// Clear categories
state.categories.value = [];
// Clear embed view
state.embedView.value = null;
// Clear poll
state.pollId.value = null;
// Clear realm
state.realm.value = null;
// Generate new draft ID for fresh composition
// Note: We don't recreate the entire state, just reset the fields
// The existing state object is reused for continuity
}
}

View File

@@ -0,0 +1,136 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
/// Utility class for common compose submit logic.
class ComposeSubmitUtils {
/// Performs the submit action for posts.
static Future<void> performSubmit(
WidgetRef ref,
ComposeState state,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
required VoidCallback onSuccess,
}) async {
if (state.submitting.value) return;
// Don't submit empty posts (no content and no attachments)
final hasContent =
state.titleController.text.trim().isNotEmpty ||
state.descriptionController.text.trim().isNotEmpty ||
state.contentController.text.trim().isNotEmpty;
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) {
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('postContentEmpty')));
}
return; // Don't submit empty posts
}
try {
state.submitting.value = true;
// Upload any local attachments first
await Future.wait(
state.attachments.value
.asMap()
.entries
.where((entry) => entry.value.isOnDevice)
.map(
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
),
);
// Prepare API request
final client = ref.read(apiClientProvider);
final isNewPost = originalPost == null;
final endpoint =
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}';
// Create request payload
final payload = {
'title': state.titleController.text,
'description': state.descriptionController.text,
'content': state.contentController.text,
if (state.slugController.text.isNotEmpty)
'slug': state.slugController.text,
'visibility': state.visibility.value,
'attachments':
state.attachments.value
.where((e) => e.isOnCloud)
.map((e) => e.data.id)
.toList(),
'type': state.postType,
if (repliedPost != null) 'replied_post_id': repliedPost.id,
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
'tags': state.tagsController.getTags,
'categories': state.categories.value.map((e) => e.slug).toList(),
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
if (state.pollId.value != null) 'poll_id': state.pollId.value,
if (state.embedView.value != null)
'embed_view': state.embedView.value!.toJson(),
};
// Send request
client.request(
endpoint,
queryParameters: {'pub': state.currentPublisher.value?.name},
data: payload,
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
);
// Call the success callback with the created/updated post
onSuccess();
} catch (err) {
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
rethrow;
} finally {
state.submitting.value = false;
}
}
/// Shows the settings sheet modal.
static void showSettingsSheet(BuildContext context, ComposeState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
/// Handles keyboard press events for compose shortcuts.
static void handleKeyPress(
KeyEvent event,
ComposeState state,
WidgetRef ref,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
}) {
ComposeLogic.handleKeyPress(
event,
state,
ref,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
);
}
}