419 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			419 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:math' as math;
 | |
| 
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter_hooks/flutter_hooks.dart';
 | |
| import 'package:gap/gap.dart';
 | |
| import 'package:go_router/go_router.dart';
 | |
| 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/screens/creators/publishers_form.dart';
 | |
| import 'package:island/screens/posts/compose.dart';
 | |
| import 'package:island/services/compose_storage_db.dart';
 | |
| import 'package:island/widgets/content/cloud_files.dart';
 | |
| import 'package:island/widgets/content/sheet.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:material_symbols_icons/symbols.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| 
 | |
| /// A dialog-compatible card widget for post composition.
 | |
| /// This extracts the core compose functionality from PostComposeScreen
 | |
| /// and adapts it for use within dialogs or other constrained layouts.
 | |
| class PostComposeCard extends HookConsumerWidget {
 | |
|   final SnPost? originalPost;
 | |
|   final PostComposeInitialState? initialState;
 | |
|   final VoidCallback? onCancel;
 | |
|   final Function()? onSubmit;
 | |
|   final Function(ComposeState)? onStateChanged;
 | |
|   final bool isContained;
 | |
|   final bool showHeader;
 | |
| 
 | |
|   const PostComposeCard({
 | |
|     super.key,
 | |
|     this.originalPost,
 | |
|     this.initialState,
 | |
|     this.onCancel,
 | |
|     this.onSubmit,
 | |
|     this.onStateChanged,
 | |
|     this.isContained = false,
 | |
|     this.showHeader = true,
 | |
|   });
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final submitted = useState(false);
 | |
| 
 | |
|     final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
 | |
|     final forwardedPost =
 | |
|         initialState?.forwardingTo ?? originalPost?.forwardedPost;
 | |
| 
 | |
|     final theme = Theme.of(context);
 | |
| 
 | |
|     // Capture the notifier to avoid using ref after dispose
 | |
|     final notifier = ref.read(composeStorageNotifierProvider.notifier);
 | |
| 
 | |
|     // 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);
 | |
| 
 | |
|     // Notify parent of state changes
 | |
|     useEffect(() {
 | |
|       onStateChanged?.call(state);
 | |
|       return null;
 | |
|     }, [state]);
 | |
| 
 | |
|     // Use shared state management utilities
 | |
|     ComposeStateUtils.usePublisherInitialization(ref, state);
 | |
|     ComposeStateUtils.useInitialStateLoader(state, initialState);
 | |
| 
 | |
|     // Dispose state when widget is disposed
 | |
|     useEffect(() {
 | |
|       return () {
 | |
|         if (!submitted.value &&
 | |
|             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(),
 | |
|             );
 | |
|             notifier
 | |
|                 .saveDraft(draft)
 | |
|                 .catchError((e) => debugPrint('Failed to save draft: $e'));
 | |
|           }
 | |
|         }
 | |
|         ComposeLogic.dispose(state);
 | |
|       };
 | |
|     }, []);
 | |
| 
 | |
|     // Helper methods
 | |
|     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: () {
 | |
|           // Mark as submitted
 | |
|           submitted.value = true;
 | |
| 
 | |
|           // Delete draft after successful submission
 | |
|           ref
 | |
|               .read(composeStorageNotifierProvider.notifier)
 | |
|               .deleteDraft(state.draftId);
 | |
| 
 | |
|           // Reset the form for new composition
 | |
|           ComposeStateUtils.resetForm(state);
 | |
| 
 | |
|           onSubmit?.call();
 | |
|         },
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     final maxHeight = math.min(640.0, MediaQuery.of(context).size.height * 0.8);
 | |
| 
 | |
|     return Card(
 | |
|       margin: EdgeInsets.zero,
 | |
|       color: isContained ? Colors.transparent : null,
 | |
|       elevation: isContained ? 0 : null,
 | |
|       child: Container(
 | |
|         constraints: BoxConstraints(maxHeight: maxHeight),
 | |
|         child: Column(
 | |
|           mainAxisSize: MainAxisSize.min,
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           children: [
 | |
|             // Header with actions
 | |
|             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,
 | |
|                       ),
 | |
|                     ),
 | |
|                     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(
 | |
|               originalPost: originalPost,
 | |
|               replyingTo: repliedPost,
 | |
|               forwardingTo: forwardedPost,
 | |
|               onReferencePostTap: (context, post) {
 | |
|                 showModalBottomSheet(
 | |
|                   context: context,
 | |
|                   isScrollControlled: true,
 | |
|                   useRootNavigator: true,
 | |
|                   builder:
 | |
|                       (context) => SheetScaffold(
 | |
|                         titleText: 'Post Preview',
 | |
|                         child: SingleChildScrollView(
 | |
|                           child: PostItem(item: post),
 | |
|                         ),
 | |
|                       ),
 | |
|                 );
 | |
|               },
 | |
|             ),
 | |
| 
 | |
|             // Main content area
 | |
|             Expanded(
 | |
|               child: KeyboardListener(
 | |
|                 focusNode: FocusNode(),
 | |
|                 onKeyEvent:
 | |
|                     (event) => ComposeLogic.handleKeyPress(
 | |
|                       event,
 | |
|                       state,
 | |
|                       ref,
 | |
|                       context,
 | |
|                       originalPost: originalPost,
 | |
|                       repliedPost: repliedPost,
 | |
|                       forwardedPost: forwardedPost,
 | |
|                     ),
 | |
|                 child: SingleChildScrollView(
 | |
|                   padding: const EdgeInsets.all(16),
 | |
|                   child: ConstrainedBox(
 | |
|                     constraints: const BoxConstraints(maxWidth: 560),
 | |
|                     child: Row(
 | |
|                       spacing: 12,
 | |
|                       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                       children: [
 | |
|                         // Publisher profile picture
 | |
|                         GestureDetector(
 | |
|                           child: ProfilePictureWidget(
 | |
|                             fileId: state.currentPublisher.value?.picture?.id,
 | |
|                             radius: 20,
 | |
|                             fallbackIcon:
 | |
|                                 state.currentPublisher.value == null
 | |
|                                     ? Symbols.question_mark
 | |
|                                     : null,
 | |
|                           ),
 | |
|                           onTap: () {
 | |
|                             if (state.currentPublisher.value == null) {
 | |
|                               // No publisher loaded, guide user to create one
 | |
|                               if (isContained) {
 | |
|                                 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;
 | |
|                                 }
 | |
|                               });
 | |
|                             }
 | |
|                           },
 | |
|                         ).padding(top: 8),
 | |
| 
 | |
|                         // Post content form
 | |
|                         Expanded(
 | |
|                           child: Column(
 | |
|                             crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                             children: [
 | |
|                               ComposeFormFields(
 | |
|                                 state: state,
 | |
|                                 showPublisherAvatar: false,
 | |
|                                 onPublisherTap: () {
 | |
|                                   if (state.currentPublisher.value == null) {
 | |
|                                     // No publisher loaded, guide user to create one
 | |
|                                     if (isContained) {
 | |
|                                       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;
 | |
|                                       }
 | |
|                                     });
 | |
|                                   }
 | |
|                                 },
 | |
|                               ),
 | |
|                               const Gap(8),
 | |
|                               ComposeAttachments(state: state, isCompact: true),
 | |
|                             ],
 | |
|                           ),
 | |
|                         ),
 | |
|                       ],
 | |
|                     ),
 | |
|                   ),
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
| 
 | |
|             // Bottom toolbar
 | |
|             SizedBox(
 | |
|               height: 65,
 | |
|               child: ClipRRect(
 | |
|                 borderRadius: const BorderRadius.only(
 | |
|                   bottomLeft: Radius.circular(8),
 | |
|                   bottomRight: Radius.circular(8),
 | |
|                 ),
 | |
|                 child: ComposeToolbar(
 | |
|                   state: state,
 | |
|                   originalPost: originalPost,
 | |
|                   isCompact: true,
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |