486 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			486 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| 
 | |
| 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: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/post_detail.dart';
 | |
| import 'package:island/services/compose_storage_db.dart';
 | |
| import 'package:island/services/responsive.dart';
 | |
| import 'package:island/widgets/app_scaffold.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/markdown.dart';
 | |
| import 'package:island/widgets/post/compose_form_fields.dart';
 | |
| import 'package:island/widgets/post/compose_shared.dart';
 | |
| import 'package:island/widgets/post/compose_settings_sheet.dart';
 | |
| import 'package:island/widgets/post/compose_toolbar.dart';
 | |
| import 'package:island/widgets/post/publishers_modal.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| 
 | |
| class ArticleEditScreen extends HookConsumerWidget {
 | |
|   final String id;
 | |
|   const ArticleEditScreen({super.key, required this.id});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final post = ref.watch(postProvider(id));
 | |
|     return post.when(
 | |
|       data: (post) => ArticleComposeScreen(originalPost: post),
 | |
|       loading:
 | |
|           () => AppScaffold(
 | |
|             appBar: AppBar(leading: const PageBackButton()),
 | |
|             body: const Center(child: CircularProgressIndicator()),
 | |
|           ),
 | |
|       error:
 | |
|           (e, _) => AppScaffold(
 | |
|             appBar: AppBar(leading: const PageBackButton()),
 | |
|             body: Text('Error: $e', textAlign: TextAlign.center),
 | |
|           ),
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class ArticleComposeScreen extends HookConsumerWidget {
 | |
|   final SnPost? originalPost;
 | |
| 
 | |
|   const ArticleComposeScreen({super.key, this.originalPost});
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context, WidgetRef ref) {
 | |
|     final theme = Theme.of(context);
 | |
|     final colorScheme = theme.colorScheme;
 | |
| 
 | |
|     final publishers = ref.watch(publishersManagedProvider);
 | |
|     final state = useMemoized(
 | |
|       () => ComposeLogic.createState(
 | |
|         originalPost: originalPost,
 | |
|         postType: 1, // Article type
 | |
|       ),
 | |
|       [originalPost],
 | |
|     );
 | |
| 
 | |
|     // Start auto-save when component mounts
 | |
|     useEffect(() {
 | |
|       Timer? autoSaveTimer;
 | |
|       if (originalPost == null) {
 | |
|         // Only auto-save for new articles, not edits
 | |
|         autoSaveTimer = Timer.periodic(const Duration(seconds: 3), (_) {
 | |
|           ComposeLogic.saveDraftWithoutUpload(ref, state);
 | |
|         });
 | |
|       }
 | |
|       return () {
 | |
|         // Stop auto-save first to prevent race conditions
 | |
|         state.stopAutoSave();
 | |
|         // Save final draft before disposing
 | |
|         if (originalPost == null) {
 | |
|           ComposeLogic.saveDraftWithoutUpload(ref, state);
 | |
|         }
 | |
|         ComposeLogic.dispose(state);
 | |
|         autoSaveTimer?.cancel();
 | |
|       };
 | |
|     }, [state]);
 | |
| 
 | |
|     final showPreview = useState(false);
 | |
|     final isAttachmentsExpanded = useState(
 | |
|       true,
 | |
|     ); // New state for attachments section
 | |
| 
 | |
|     // Initialize publisher once when data is available
 | |
|     useEffect(() {
 | |
|       if (publishers.value?.isNotEmpty ?? false) {
 | |
|         state.currentPublisher.value = publishers.value!.first;
 | |
|       }
 | |
|       return null;
 | |
|     }, [publishers]);
 | |
| 
 | |
|     // Load draft if available (only for new articles)
 | |
|     useEffect(() {
 | |
|       if (originalPost == null) {
 | |
|         // Try to load the most recent article 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;
 | |
|     }, []);
 | |
| 
 | |
|     // Helper methods
 | |
|     void showSettingsSheet() {
 | |
|       showModalBottomSheet(
 | |
|         context: context,
 | |
|         isScrollControlled: true,
 | |
|         builder: (context) => ComposeSettingsSheet(state: state),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Widget buildPreviewPane() {
 | |
|       final widgetItem = SingleChildScrollView(
 | |
|         padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
 | |
|         child: ValueListenableBuilder<TextEditingValue>(
 | |
|           valueListenable: state.titleController,
 | |
|           builder: (context, titleValue, _) {
 | |
|             return ValueListenableBuilder<TextEditingValue>(
 | |
|               valueListenable: state.descriptionController,
 | |
|               builder: (context, descriptionValue, _) {
 | |
|                 return ValueListenableBuilder<TextEditingValue>(
 | |
|                   valueListenable: state.contentController,
 | |
|                   builder: (context, contentValue, _) {
 | |
|                     return Column(
 | |
|                       crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                       children: [
 | |
|                         if (titleValue.text.isNotEmpty) ...[
 | |
|                           Text(
 | |
|                             titleValue.text,
 | |
|                             style: theme.textTheme.headlineSmall?.copyWith(
 | |
|                               fontWeight: FontWeight.bold,
 | |
|                             ),
 | |
|                           ),
 | |
|                           const Gap(16),
 | |
|                         ],
 | |
|                         if (descriptionValue.text.isNotEmpty) ...[
 | |
|                           Text(
 | |
|                             descriptionValue.text,
 | |
|                             style: theme.textTheme.bodyLarge?.copyWith(
 | |
|                               color: colorScheme.onSurface.withOpacity(0.7),
 | |
|                             ),
 | |
|                           ),
 | |
|                           const Gap(16),
 | |
|                         ],
 | |
|                         if (contentValue.text.isNotEmpty)
 | |
|                           MarkdownTextContent(
 | |
|                             content: contentValue.text,
 | |
|                             textStyle: theme.textTheme.bodyMedium,
 | |
|                             attachments:
 | |
|                                 state.attachments.value
 | |
|                                     .where((e) => e.isOnCloud)
 | |
|                                     .map((e) => e.data)
 | |
|                                     .cast<SnCloudFile>()
 | |
|                                     .toList(),
 | |
|                           ),
 | |
|                       ],
 | |
|                     );
 | |
|                   },
 | |
|                 );
 | |
|               },
 | |
|             );
 | |
|           },
 | |
|         ),
 | |
|       );
 | |
| 
 | |
|       if (isWideScreen(context)) {
 | |
|         return Align(alignment: Alignment.topLeft, child: widgetItem);
 | |
|       }
 | |
| 
 | |
|       return Container(
 | |
|         decoration: BoxDecoration(
 | |
|           border: Border.all(color: colorScheme.outline.withOpacity(0.3)),
 | |
|           borderRadius: BorderRadius.circular(8),
 | |
|         ),
 | |
|         child: Column(
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           children: [
 | |
|             Container(
 | |
|               padding: const EdgeInsets.all(16),
 | |
|               decoration: BoxDecoration(
 | |
|                 color: colorScheme.surfaceVariant.withOpacity(0.3),
 | |
|                 borderRadius: const BorderRadius.only(
 | |
|                   topLeft: Radius.circular(8),
 | |
|                   topRight: Radius.circular(8),
 | |
|                 ),
 | |
|               ),
 | |
|               child: Row(
 | |
|                 children: [
 | |
|                   Icon(Symbols.preview, size: 20),
 | |
|                   const Gap(8),
 | |
|                   Text('preview'.tr(), style: theme.textTheme.titleMedium),
 | |
|                 ],
 | |
|               ),
 | |
|             ),
 | |
|             Expanded(child: widgetItem),
 | |
|           ],
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     Widget buildEditorPane() {
 | |
|       return Center(
 | |
|         child: ConstrainedBox(
 | |
|           constraints: const BoxConstraints(maxWidth: 560),
 | |
|           child: Column(
 | |
|             crossAxisAlignment: CrossAxisAlignment.start,
 | |
|             children: [
 | |
|               ComposeFormFields(
 | |
|                 state: state,
 | |
|                 showPublisherAvatar: false,
 | |
|                 onPublisherTap: () {
 | |
|                   showModalBottomSheet(
 | |
|                     isScrollControlled: true,
 | |
|                     context: context,
 | |
|                     builder: (context) => const PublisherModal(),
 | |
|                   ).then((value) {
 | |
|                     if (value != null) {
 | |
|                       state.currentPublisher.value = value;
 | |
|                     }
 | |
|                   });
 | |
|                 },
 | |
|               ),
 | |
| 
 | |
|               // Attachments preview
 | |
|               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: isAttachmentsExpanded.value,
 | |
|                       onExpansionChanged: (expanded) {
 | |
|                         isAttachmentsExpanded.value = expanded;
 | |
|                       },
 | |
|                       collapsedBackgroundColor:
 | |
|                           Theme.of(context).colorScheme.surfaceContainer,
 | |
|                       title: Column(
 | |
|                         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|                         children: [
 | |
|                           Text('attachments').tr(),
 | |
|                           Text(
 | |
|                             'articleAttachmentHint'.tr(),
 | |
|                             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,
 | |
|                                           ),
 | |
|                                     ),
 | |
|                                   ),
 | |
|                               ],
 | |
|                             );
 | |
|                           },
 | |
|                         ),
 | |
|                         Gap(16),
 | |
|                       ],
 | |
|                     ),
 | |
|                   );
 | |
|                 },
 | |
|               ),
 | |
|             ],
 | |
|           ),
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     return PopScope(
 | |
|       onPopInvoked: (_) {
 | |
|         if (originalPost == null) {
 | |
|           ComposeLogic.saveDraftWithoutUpload(ref, state);
 | |
|         }
 | |
|       },
 | |
|       child: AppScaffold(
 | |
|         isNoBackground: false,
 | |
|         appBar: AppBar(
 | |
|           leading: const PageBackButton(),
 | |
|           title: ValueListenableBuilder<TextEditingValue>(
 | |
|             valueListenable: state.titleController,
 | |
|             builder: (context, titleValue, _) {
 | |
|               return Text(
 | |
|                 titleValue.text.isEmpty ? 'postTitle'.tr() : titleValue.text,
 | |
|               );
 | |
|             },
 | |
|           ),
 | |
|           actions: [
 | |
|             // Info banner for article compose
 | |
|             const SizedBox.shrink(),
 | |
|             IconButton(
 | |
|               icon: ProfilePictureWidget(
 | |
|                 fileId: state.currentPublisher.value?.picture?.id,
 | |
|                 radius: 12,
 | |
|                 fallbackIcon:
 | |
|                     state.currentPublisher.value == null
 | |
|                         ? Symbols.question_mark
 | |
|                         : null,
 | |
|               ),
 | |
|               onPressed: () {
 | |
|                 showModalBottomSheet(
 | |
|                   isScrollControlled: true,
 | |
|                   context: context,
 | |
|                   builder: (context) => const PublisherModal(),
 | |
|                 ).then((value) {
 | |
|                   if (value != null) {
 | |
|                     state.currentPublisher.value = value;
 | |
|                   }
 | |
|                 });
 | |
|               },
 | |
|             ),
 | |
|             IconButton(
 | |
|               icon: const Icon(Symbols.settings),
 | |
|               onPressed: showSettingsSheet,
 | |
|               tooltip: 'postSettings'.tr(),
 | |
|             ),
 | |
|             Tooltip(
 | |
|               message: 'togglePreview'.tr(),
 | |
|               child: IconButton(
 | |
|                 icon: Icon(showPreview.value ? Symbols.edit : Symbols.preview),
 | |
|                 onPressed: () => showPreview.value = !showPreview.value,
 | |
|               ),
 | |
|             ),
 | |
|             ValueListenableBuilder<bool>(
 | |
|               valueListenable: state.submitting,
 | |
|               builder: (context, submitting, _) {
 | |
|                 return IconButton(
 | |
|                   onPressed:
 | |
|                       submitting
 | |
|                           ? null
 | |
|                           : () => ComposeLogic.performAction(
 | |
|                             ref,
 | |
|                             state,
 | |
|                             context,
 | |
|                             originalPost: originalPost,
 | |
|                           ),
 | |
|                   icon:
 | |
|                       submitting
 | |
|                           ? SizedBox(
 | |
|                             width: 28,
 | |
|                             height: 28,
 | |
|                             child: const CircularProgressIndicator(
 | |
|                               color: Colors.white,
 | |
|                               strokeWidth: 2.5,
 | |
|                             ),
 | |
|                           ).center()
 | |
|                           : Icon(
 | |
|                             originalPost != null
 | |
|                                 ? Symbols.edit
 | |
|                                 : Symbols.upload,
 | |
|                           ),
 | |
|                 );
 | |
|               },
 | |
|             ),
 | |
|             const Gap(8),
 | |
|           ],
 | |
|         ),
 | |
|         body: Column(
 | |
|           children: [
 | |
|             Expanded(
 | |
|               child: Padding(
 | |
|                 padding: const EdgeInsets.only(left: 16, right: 16),
 | |
|                 child:
 | |
|                     isWideScreen(context)
 | |
|                         ? Row(
 | |
|                           spacing: 16,
 | |
|                           children: [
 | |
|                             Expanded(
 | |
|                               flex: showPreview.value ? 1 : 2,
 | |
|                               child: buildEditorPane(),
 | |
|                             ),
 | |
|                             if (showPreview.value) const VerticalDivider(),
 | |
|                             if (showPreview.value)
 | |
|                               Expanded(child: buildPreviewPane()),
 | |
|                           ],
 | |
|                         )
 | |
|                         : showPreview.value
 | |
|                         ? buildPreviewPane()
 | |
|                         : buildEditorPane(),
 | |
|               ),
 | |
|             ),
 | |
| 
 | |
|             // Bottom toolbar
 | |
|             ComposeToolbar(state: state, originalPost: originalPost),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |