diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index a6f8adb1..2f248960 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -77,16 +77,32 @@ class ChatInput extends HookConsumerWidget { } Future handlePaste() async { - final clipboard = await Pasteboard.image; - if (clipboard == null) return; + final image = await Pasteboard.image; + if (image != null) { + onAttachmentsChanged([ + ...attachments, + UniversalFile( + data: XFile.fromData(image, mimeType: "image/jpeg"), + type: UniversalFileType.image, + ), + ]); + return; + } - onAttachmentsChanged([ - ...attachments, - UniversalFile( - data: XFile.fromData(clipboard, mimeType: "image/jpeg"), - type: UniversalFileType.image, - ), - ]); + final textData = await Clipboard.getData(Clipboard.kTextPlain); + if (textData != null && textData.text != null) { + final text = messageController.text; + final selection = messageController.selection; + 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) { diff --git a/lib/widgets/post/compose_attachments.dart b/lib/widgets/post/compose_attachments.dart new file mode 100644 index 00000000..c01d7d1f --- /dev/null +++ b/lib/widgets/post/compose_attachments.dart @@ -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( + 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>( + 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>( + 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), + ], + ), + ); + }, + ); + } +} diff --git a/lib/widgets/post/compose_card.dart b/lib/widgets/post/compose_card.dart index 94a43205..e0a7bc1e 100644 --- a/lib/widgets/post/compose_card.dart +++ b/lib/widgets/post/compose_card.dart @@ -1,4 +1,3 @@ -import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.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/post.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/posts/compose.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/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_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/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:styled_widget/styled_widget.dart'; @@ -54,7 +54,6 @@ class PostComposeCard extends HookConsumerWidget { initialState?.forwardingTo ?? originalPost?.forwardedPost; final theme = Theme.of(context); - final publishers = ref.watch(publishersManagedProvider); // Capture the notifier to avoid using ref after dispose final notifier = ref.read(composeStorageNotifierProvider.notifier); @@ -92,31 +91,9 @@ class PostComposeCard extends HookConsumerWidget { return null; }, [state]); - // Initialize publisher once when data is available - useEffect(() { - if (publishers.value?.isNotEmpty ?? false) { - 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]); + // Use shared state management utilities + ComposeStateUtils.usePublisherInitialization(ref, state); + ComposeStateUtils.useInitialStateLoader(state, initialState); // Dispose state when widget is disposed 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 void showSettingsSheet() { showModalBottomSheet( @@ -202,211 +142,28 @@ class PostComposeCard extends HookConsumerWidget { } Future 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) - final hasContent = - state.titleController.text.trim().isNotEmpty || - state.descriptionController.text.trim().isNotEmpty || - state.contentController.text.trim().isNotEmpty; - final hasAttachments = state.attachments.value.isNotEmpty; + // Delete draft after successful submission + ref + .read(composeStorageNotifierProvider.notifier) + .deleteDraft(state.draftId); - if (!hasContent && !hasAttachments) { - // Show error message if context is mounted - 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( - 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, - ); - }, - ); + // Reset the form for new composition + ComposeStateUtils.resetForm(state); }, ); } - 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( - 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( margin: EdgeInsets.zero, color: Theme.of(context).colorScheme.surfaceContainer, @@ -489,7 +246,58 @@ class PostComposeCard extends HookConsumerWidget { ), // 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 Expanded( @@ -557,114 +365,44 @@ class PostComposeCard extends HookConsumerWidget { 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( - Symbols.info, - size: 16, - color: theme.colorScheme.primary, - ), - const Gap(8), - Expanded( - child: Text( - 'Tap the avatar to create a publisher and start composing.', - style: theme.textTheme.bodySmall - ?.copyWith( - color: - theme - .colorScheme - .onSurfaceVariant, - ), - ), - ), - ], - ), - ), - 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(), + ComposeFormFields( + state: state, + showPublisherAvatar: false, + onPublisherTap: () { + if (state.currentPublisher.value == null) { + // No publisher loaded, guide user to create one + if (isInDialog) { + Navigator.of(context).pop(); + } + context.pushNamed('creatorNew').then(( + value, + ) { + if (value != null) { + state.currentPublisher.value = + value as SnPublisher; + ref.invalidate( + publishersManagedProvider, + ); + } + }); + } else { + // Show modal to select from existing publishers + showModalBottomSheet( + isScrollControlled: true, + useRootNavigator: true, + context: context, + builder: + (context) => const PublisherModal(), + ).then((value) { + if (value != null) { + state.currentPublisher.value = value; + } + }); + } + }, ), - 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), - - // Attachments preview - if (state.attachments.value.isNotEmpty) - LayoutBuilder( - builder: (context, constraints) { - final isWide = isWideScreen(context); - return isWide - ? buildWideAttachmentGrid() - : buildNarrowAttachmentList(); - }, - ) - else - const SizedBox.shrink(), + ComposeAttachments(state: state, isCompact: true), ], ), ), @@ -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, - ), - ], - ), - ), - ); - } } diff --git a/lib/widgets/post/compose_form_fields.dart b/lib/widgets/post/compose_form_fields.dart new file mode 100644 index 00000000..dfe7d084 --- /dev/null +++ b/lib/widgets/post/compose_form_fields.dart @@ -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(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/post/compose_info_banner.dart b/lib/widgets/post/compose_info_banner.dart new file mode 100644 index 00000000..4ae3fc98 --- /dev/null +++ b/lib/widgets/post/compose_info_banner.dart @@ -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, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/post/compose_state_utils.dart b/lib/widgets/post/compose_state_utils.dart new file mode 100644 index 00000000..29712be3 --- /dev/null +++ b/lib/widgets/post/compose_state_utils.dart @@ -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 + } +} diff --git a/lib/widgets/post/compose_submit_utils.dart b/lib/widgets/post/compose_submit_utils.dart new file mode 100644 index 00000000..cf1b705f --- /dev/null +++ b/lib/widgets/post/compose_submit_utils.dart @@ -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 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, + ); + } +}