import 'package:auto_route/auto_route.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:island/models/file.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; import 'package:island/screens/creators/publishers.dart'; import 'package:island/screens/posts/detail.dart'; import 'package:island/services/file.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/post/publishers_modal.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:pasteboard/pasteboard.dart'; import 'package:styled_widget/styled_widget.dart'; @RoutePage() class PostEditScreen extends HookConsumerWidget { final String id; const PostEditScreen({super.key, @PathParam('id') required this.id}); @override Widget build(BuildContext context, WidgetRef ref) { final post = ref.watch(postProvider(id)); return post.when( data: (post) => PostComposeScreen(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), ), ); } } @RoutePage() class PostComposeScreen extends HookConsumerWidget { final SnPost? originalPost; final SnPost? repliedPost; final SnPost? forwardedPost; const PostComposeScreen({ super.key, this.originalPost, this.repliedPost, this.forwardedPost, }); @override Widget build(BuildContext context, WidgetRef ref) { // Extract common theme and localization to avoid repeated lookups final theme = Theme.of(context); final colorScheme = theme.colorScheme; final publishers = ref.watch(publishersManagedProvider); final currentPublisher = useState(null); // Initialize publisher once when data is available useEffect(() { if (publishers.value?.isNotEmpty ?? false) { currentPublisher.value = publishers.value!.first; } return null; }, [publishers]); // State management final attachments = useState>( originalPost?.attachments .map( (e) => UniversalFile( data: e, type: switch (e.mimeType?.split('/').firstOrNull) { 'image' => UniversalFileType.image, 'video' => UniversalFileType.video, 'audio' => UniversalFileType.audio, _ => UniversalFileType.file, }, ), ) .toList() ?? [], ); final titleController = useTextEditingController(text: originalPost?.title); final descriptionController = useTextEditingController( text: originalPost?.description, ); final contentController = useTextEditingController( text: originalPost?.content ?? (forwardedPost != null ? '> ${forwardedPost!.content}\n\n' : null), ); final visibility = useState(originalPost?.visibility ?? 0); final submitting = useState(false); final attachmentProgress = useState>({}); // Media handling functions Future pickPhotoMedia() async { final result = await ref .watch(imagePickerProvider) .pickMultiImage(requestFullMetadata: true); if (result.isEmpty) return; attachments.value = [ ...attachments.value, ...result.map( (e) => UniversalFile(data: e, type: UniversalFileType.image), ), ]; } Future pickVideoMedia() async { final result = await ref .watch(imagePickerProvider) .pickVideo(source: ImageSource.gallery); if (result == null) return; attachments.value = [ ...attachments.value, UniversalFile(data: result, type: UniversalFileType.video), ]; } // Helper method to get mimetype from file type String getMimeTypeFromFileType(UniversalFileType type) { return switch (type) { UniversalFileType.image => 'image/unknown', UniversalFileType.video => 'video/unknown', UniversalFileType.audio => 'audio/unknown', UniversalFileType.file => 'application/octet-stream', }; } // Attachment management functions Future uploadAttachment(int index) async { final attachment = attachments.value[index]; if (attachment.isOnCloud) return; final baseUrl = ref.watch(serverUrlProvider); final token = await getToken(ref.watch(tokenProvider)); if (token == null) throw ArgumentError('Token is null'); try { // Update progress state attachmentProgress.value = {...attachmentProgress.value, index: 0}; // Upload file to cloud final cloudFile = await putMediaToCloud( fileData: attachment, atk: token, baseUrl: baseUrl, filename: attachment.data.name ?? 'Post media', mimetype: attachment.data.mimeType ?? getMimeTypeFromFileType(attachment.type), onProgress: (progress, _) { attachmentProgress.value = { ...attachmentProgress.value, index: progress, }; }, ).future; if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); } // Update attachments list with cloud file final clone = List.of(attachments.value); clone[index] = UniversalFile(data: cloudFile, type: attachment.type); attachments.value = clone; } catch (err) { showErrorAlert(err); } finally { // Clean up progress state attachmentProgress.value = {...attachmentProgress.value}..remove(index); } } // Helper method to move attachment in the list List moveAttachment( List attachments, int idx, int delta, ) { if (idx + delta < 0 || idx + delta >= attachments.length) { return attachments; } final clone = List.of(attachments); clone.insert(idx + delta, clone.removeAt(idx)); return clone; } Future deleteAttachment(int index) async { final attachment = attachments.value[index]; if (attachment.isOnCloud) { final client = ref.watch(apiClientProvider); await client.delete('/files/${attachment.data.id}'); } final clone = List.of(attachments.value); clone.removeAt(index); attachments.value = clone; } // Form submission Future performAction() async { if (submitting.value) return; try { submitting.value = true; // Upload any local attachments first await Future.wait( attachments.value .asMap() .entries .where((entry) => entry.value.isOnDevice) .map((entry) => uploadAttachment(entry.key)), ); // Prepare API request final client = ref.watch(apiClientProvider); final isNewPost = originalPost == null; final endpoint = isNewPost ? '/posts' : '/posts/${originalPost!.id}'; // Create request payload final payload = { 'title': titleController.text, 'description': descriptionController.text, 'content': contentController.text, 'visibility': visibility.value, 'attachments': attachments.value .where((e) => e.isOnCloud) .map((e) => e.data.id) .toList(), if (repliedPost != null) 'replied_post_id': repliedPost!.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost!.id, }; // Send request await client.request( endpoint, data: payload, options: Options( headers: {'X-Pub': currentPublisher.value?.name}, method: isNewPost ? 'POST' : 'PATCH', ), ); if (context.mounted) { context.maybePop(true); } } catch (err) { showErrorAlert(err); } finally { submitting.value = false; } } // Clipboard handling Future handlePaste() async { final clipboard = await Pasteboard.image; if (clipboard == null) return; attachments.value = [ ...attachments.value, UniversalFile( data: XFile.fromData(clipboard, mimeType: "image/jpeg"), type: UniversalFileType.image, ), ]; } void handleKeyPress(RawKeyEvent event) { if (event is! RawKeyDownEvent) return; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isModifierPressed = event.isMetaPressed || event.isControlPressed; final isSubmit = event.logicalKey == LogicalKeyboardKey.enter; if (isPaste && isModifierPressed) { handlePaste(); } else if (isSubmit && isModifierPressed && !submitting.value) { performAction(); } } // Helper method to build visibility option Widget buildVisibilityOption( BuildContext context, int value, IconData icon, String textKey, ) { return ListTile( leading: Icon(icon), title: Text(textKey.tr()), onTap: () { visibility.value = value; Navigator.pop(context); }, selected: visibility.value == value, ); } // Helper method to get the appropriate icon for each visibility status IconData getVisibilityIcon(int visibilityValue) { switch (visibilityValue) { case 1: // Friends return Symbols.group; case 2: // Unlisted return Symbols.link_off; case 3: // Private return Symbols.lock; default: // Public (0) or unknown return Symbols.public; } } // Helper method to get the translation key for each visibility status String getVisibilityText(int visibilityValue) { switch (visibilityValue) { case 1: // Friends return 'postVisibilityFriends'; case 2: // Unlisted return 'postVisibilityUnlisted'; case 3: // Private return 'postVisibilityPrivate'; default: // Public (0) or unknown return 'postVisibilityPublic'; } } // Visibility handling void showVisibilityModal() { showDialog( context: context, builder: (context) => AlertDialog( title: Text('postVisibility'.tr()), content: Column( mainAxisSize: MainAxisSize.min, children: [ buildVisibilityOption( context, 0, Symbols.public, 'postVisibilityPublic', ), buildVisibilityOption( context, 1, Symbols.group, 'postVisibilityFriends', ), buildVisibilityOption( context, 2, Symbols.link_off, 'postVisibilityUnlisted', ), buildVisibilityOption( context, 3, Symbols.lock, 'postVisibilityPrivate', ), ], ), ), ); } // Show keyboard shortcuts dialog void showKeyboardShortcutsDialog() { showDialog( context: context, builder: (context) => AlertDialog( title: Text('keyboard_shortcuts'.tr()), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'), Text('Ctrl/Cmd + V: ${'paste'.tr()}'), Text('Ctrl/Cmd + I: ${'add_image'.tr()}'), Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('close'.tr()), ), ], ), ); } // Helper method to build wide attachment grid Widget buildWideAttachmentGrid( BoxConstraints constraints, List attachments, Map progress, ) { return Wrap( spacing: 8, runSpacing: 8, children: [ for (var idx = 0; idx < attachments.length; idx++) SizedBox( width: constraints.maxWidth / 2 - 4, child: AttachmentPreview( item: attachments[idx], progress: progress[idx], onRequestUpload: () => uploadAttachment(idx), onDelete: () => deleteAttachment(idx), onMove: (delta) => moveAttachment(attachments, idx, delta), ), ), ], ); } // Helper method to build narrow attachment list Widget buildNarrowAttachmentList( List attachments, Map progress, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ for (var idx = 0; idx < attachments.length; idx++) AttachmentPreview( item: attachments[idx], progress: progress[idx], onRequestUpload: () => uploadAttachment(idx), onDelete: () => deleteAttachment(idx), onMove: (delta) => moveAttachment(attachments, idx, delta), ), ], ); } // Build UI return AppScaffold( appBar: AppBar( leading: const PageBackButton(), title: isWideScreen(context) ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr()) : null, actions: [ if (isWideScreen(context)) Tooltip( message: 'keyboard_shortcuts'.tr(), child: IconButton( icon: const Icon(Symbols.keyboard), onPressed: showKeyboardShortcutsDialog, ), ), IconButton( onPressed: submitting.value ? null : performAction, icon: submitting.value ? 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Reply/Forward info section if (repliedPost != null) _buildInfoBanner( context, Symbols.reply, 'reply', repliedPost!.publisher.nick, ), if (forwardedPost != null) _buildInfoBanner( context, Symbols.forward, 'forward', forwardedPost!.publisher.nick, ), // Main content area Expanded( child: Row( spacing: 12, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Publisher profile picture GestureDetector( child: ProfilePictureWidget( fileId: currentPublisher.value?.picture?.id, radius: 20, fallbackIcon: currentPublisher.value == null ? Symbols.question_mark : null, ), onTap: () { showModalBottomSheet( isScrollControlled: true, context: context, builder: (context) => PublisherModal(), ).then((value) { if (value is SnPublisher) currentPublisher.value = value; }); }, ).padding(top: 16), // Post content form Expanded( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Visibility selector Row( children: [ OutlinedButton( onPressed: showVisibilityModal, style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), side: BorderSide( color: colorScheme.primary.withOpacity(0.5), ), padding: const EdgeInsets.symmetric( horizontal: 16, ), visualDensity: const VisualDensity( vertical: -2, horizontal: -4, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( getVisibilityIcon(visibility.value), size: 16, color: colorScheme.primary, ), const SizedBox(width: 6), Text( getVisibilityText(visibility.value).tr(), style: TextStyle( fontSize: 14, color: colorScheme.primary, ), ), ], ), ), ], ).padding(bottom: 6), // Title field TextField( controller: titleController, decoration: InputDecoration.collapsed( hintText: 'postTitle'.tr(), ), style: const TextStyle(fontSize: 16), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), // Description field TextField( controller: descriptionController, decoration: InputDecoration.collapsed( hintText: 'postDescription'.tr(), ), style: const TextStyle(fontSize: 16), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), const Gap(8), // Content field with keyboard listener RawKeyboardListener( focusNode: FocusNode(), onKey: handleKeyPress, child: TextField( controller: contentController, style: const TextStyle(fontSize: 14), decoration: InputDecoration( border: InputBorder.none, hintText: 'postPlaceholder'.tr(), isDense: true, ), maxLines: null, onTapOutside: (_) => FocusManager.instance.primaryFocus ?.unfocus(), ), ), const Gap(8), // Attachments preview LayoutBuilder( builder: (context, constraints) { final isWide = isWideScreen(context); return isWide ? buildWideAttachmentGrid( constraints, attachments.value, attachmentProgress.value, ) : buildNarrowAttachmentList( attachments.value, attachmentProgress.value, ); }, ), ], ), ), ), ], ).padding(horizontal: 16), ), // Bottom toolbar Material( elevation: 4, child: Row( children: [ IconButton( onPressed: pickPhotoMedia, icon: const Icon(Symbols.add_a_photo), color: colorScheme.primary, ), IconButton( onPressed: pickVideoMedia, icon: const Icon(Symbols.videocam), color: colorScheme.primary, ), ], ).padding( bottom: MediaQuery.of(context).padding.bottom + 16, horizontal: 16, top: 8, ), ), ], ), ); } // Helper method to build info banner for replied/forwarded posts Widget _buildInfoBanner( BuildContext context, IconData icon, String labelKey, String publisherNick, ) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), child: Row( children: [ Icon(icon, size: 16), const Gap(8), Expanded( child: Text( '${'labelKey'.tr()}: $publisherNick', style: Theme.of(context).textTheme.bodySmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ); } }