Compare commits
	
		
			2 Commits
		
	
	
		
			52111c4b95
			...
			89fd80bcb8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 89fd80bcb8 | |||
| ab4f4faafe | 
| @@ -337,6 +337,9 @@ | ||||
|   "unauthorized": "Unauthorized", | ||||
|   "unauthorizedHint": "You're not signed in or session expired, please sign in again.", | ||||
|   "publisherBelongsTo": "Belongs to {}", | ||||
|   "postContent": "Content", | ||||
|   "postSettings": "Settings", | ||||
|   "postPublisherUnselected": "Publisher Unspecified", | ||||
|   "postVisibility": "Visibility", | ||||
|   "postVisibilityPublic": "Public", | ||||
|   "postVisibilityFriends": "Friends Only", | ||||
| @@ -449,5 +452,7 @@ | ||||
|   "checkInResultT4": "Best", | ||||
|   "accountProfileView": "View Profile", | ||||
|   "unspecified": "Unspecified", | ||||
|   "added": "Added" | ||||
|   "added": "Added", | ||||
|   "preview": "Preview", | ||||
|   "togglePreview": "Toggle Preview" | ||||
| } | ||||
|   | ||||
| @@ -140,6 +140,8 @@ PODS: | ||||
|     - nanopb/encode (= 3.30910.0) | ||||
|   - nanopb/decode (3.30910.0) | ||||
|   - nanopb/encode (3.30910.0) | ||||
|   - native_exif (0.0.1): | ||||
|     - Flutter | ||||
|   - OrderedSet (6.0.3) | ||||
|   - package_info_plus (0.4.5): | ||||
|     - Flutter | ||||
| @@ -218,6 +220,7 @@ DEPENDENCIES: | ||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`) | ||||
|   - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) | ||||
|   - native_exif (from `.symlinks/plugins/native_exif/ios`) | ||||
|   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) | ||||
|   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) | ||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) | ||||
| @@ -292,6 +295,8 @@ EXTERNAL SOURCES: | ||||
|     :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" | ||||
|   media_kit_video: | ||||
|     :path: ".symlinks/plugins/media_kit_video/ios" | ||||
|   native_exif: | ||||
|     :path: ".symlinks/plugins/native_exif/ios" | ||||
|   package_info_plus: | ||||
|     :path: ".symlinks/plugins/package_info_plus/ios" | ||||
|   pasteboard: | ||||
| @@ -349,6 +354,7 @@ SPEC CHECKSUMS: | ||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||
|   native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c | ||||
|   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 | ||||
|   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 | ||||
|   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c | ||||
|   | ||||
| @@ -8,14 +8,14 @@ class AppRouter extends RootStackRouter { | ||||
|  | ||||
|   @override | ||||
|   List<AutoRoute> get routes => [ | ||||
|     AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'), | ||||
|     AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'), | ||||
|     AutoRoute( | ||||
|       page: ExploreShellRoute.page, | ||||
|       path: '/', | ||||
|       children: [ | ||||
|         AutoRoute(page: ExploreRoute.page, path: ''), | ||||
|         AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'), | ||||
|         AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'), | ||||
|         AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'), | ||||
|         AutoRoute(page: PublisherProfileRoute.page, path: 'publishers/:name'), | ||||
|       ], | ||||
|     ), | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,9 +1,12 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:island/widgets/post/post_list.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class CreatorPostListScreen extends HookConsumerWidget { | ||||
| @@ -15,13 +18,62 @@ class CreatorPostListScreen extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final refreshKey = useState(0); | ||||
|  | ||||
|     void showCreatePostSheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         builder: | ||||
|             (context) => SheetScaffold( | ||||
|               titleText: 'create'.tr(), | ||||
|               child: Column( | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.edit), | ||||
|                     title: Text('postContent'.tr()), | ||||
|                     subtitle: Text('Create a regular post'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|                       final result = await context.router.pushPath( | ||||
|                         '/posts/compose?type=0', | ||||
|                       ); | ||||
|                       if (result == true) { | ||||
|                         refreshKey.value++; | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: const Icon(Symbols.article), | ||||
|                     title: Text('Article'), | ||||
|                     subtitle: Text('Create a detailed article'), | ||||
|                     onTap: () async { | ||||
|                       Navigator.pop(context); | ||||
|                       final result = await context.router.pushPath( | ||||
|                         '/posts/compose?type=1', | ||||
|                       ); | ||||
|                       if (result == true) { | ||||
|                         refreshKey.value++; | ||||
|                       } | ||||
|                     }, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar(title: Text('posts').tr()), | ||||
|       body: CustomScrollView( | ||||
|         key: ValueKey(refreshKey.value), | ||||
|         slivers: [ | ||||
|           SliverPostList(pubName: pubName, itemType: PostItemType.creator), | ||||
|         ], | ||||
|       ), | ||||
|       floatingActionButton: FloatingActionButton( | ||||
|         onPressed: showCreatePostSheet, | ||||
|         child: const Icon(Symbols.add), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,28 +1,22 @@ | ||||
| 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/screens/posts/compose_article.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/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/compose_shared.dart'; | ||||
| import 'package:island/widgets/post/post_item.dart'; | ||||
| import 'package:island/widgets/post/publishers_modal.dart'; | ||||
| import 'package:island/screens/posts/detail.dart'; | ||||
| import 'package:island/widgets/post/compose_settings_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| @@ -54,296 +48,70 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|   final SnPost? originalPost; | ||||
|   final SnPost? repliedPost; | ||||
|   final SnPost? forwardedPost; | ||||
|   final int? type; | ||||
|   const PostComposeScreen({ | ||||
|     super.key, | ||||
|     this.originalPost, | ||||
|     this.repliedPost, | ||||
|     this.forwardedPost, | ||||
|     @QueryParam('type') this.type, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     // Determine the compose type: auto-detect from edited post or use query parameter | ||||
|     final composeType = originalPost?.type ?? type ?? 0; | ||||
|  | ||||
|     // If type is 1 (article), return ArticleComposeScreen | ||||
|     if (composeType == 1) { | ||||
|       return ArticleComposeScreen(originalPost: originalPost); | ||||
|     } | ||||
|  | ||||
|     // Otherwise, continue with regular post compose | ||||
|     final theme = Theme.of(context); | ||||
|     final colorScheme = theme.colorScheme; | ||||
|  | ||||
|     final publishers = ref.watch(publishersManagedProvider); | ||||
|     final state = useMemoized( | ||||
|       () => ComposeLogic.createState( | ||||
|         originalPost: originalPost, | ||||
|         forwardedPost: forwardedPost, | ||||
|       ), | ||||
|       [originalPost, forwardedPost], | ||||
|     ); | ||||
|  | ||||
|     final currentPublisher = useState<SnPublisher?>(null); | ||||
|  | ||||
|     // Initialize publisher once when data is available | ||||
|     useEffect(() { | ||||
|       if (publishers.value?.isNotEmpty ?? false) { | ||||
|         currentPublisher.value = publishers.value!.first; | ||||
|         state.currentPublisher.value = publishers.value!.first; | ||||
|       } | ||||
|       return null; | ||||
|     }, [publishers]); | ||||
|  | ||||
|     // Contains the XFile, ByteData, or SnCloudFile | ||||
|     final attachments = useState<List<UniversalFile>>( | ||||
|       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), | ||||
|     ); | ||||
|     // Dispose state when widget is disposed | ||||
|     useEffect(() { | ||||
|       return () => ComposeLogic.dispose(state); | ||||
|     }, []); | ||||
|  | ||||
|     // Add visibility state with default value from original post or 0 (public) | ||||
|     final visibility = useState<int>(originalPost?.visibility ?? 0); | ||||
|     // Helper methods | ||||
|  | ||||
|     final submitting = useState(false); | ||||
|  | ||||
|     Future<void> 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<void> 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), | ||||
|       ]; | ||||
|     } | ||||
|  | ||||
|     final attachmentProgress = useState<Map<int, double>>({}); | ||||
|  | ||||
|     Future<void> uploadAttachment(int index) async { | ||||
|       final attachment = attachments.value[index]; | ||||
|       if (attachment is SnCloudFile) return; | ||||
|       final baseUrl = ref.watch(serverUrlProvider); | ||||
|       final token = await getToken(ref.watch(tokenProvider)); | ||||
|       if (token == null) throw ArgumentError('Token is null'); | ||||
|       try { | ||||
|         attachmentProgress.value = {...attachmentProgress.value, index: 0}; | ||||
|         final cloudFile = | ||||
|             await putMediaToCloud( | ||||
|               fileData: attachment, | ||||
|               atk: token, | ||||
|               baseUrl: baseUrl, | ||||
|               filename: attachment.data.name ?? 'Post media', | ||||
|               mimetype: | ||||
|                   attachment.data.mimeType ?? | ||||
|                   switch (attachment.type) { | ||||
|                     UniversalFileType.image => 'image/unknown', | ||||
|                     UniversalFileType.video => 'video/unknown', | ||||
|                     UniversalFileType.audio => 'audio/unknown', | ||||
|                     UniversalFileType.file => 'application/octet-stream', | ||||
|                   }, | ||||
|               onProgress: (progress, estimate) { | ||||
|                 attachmentProgress.value = { | ||||
|                   ...attachmentProgress.value, | ||||
|                   index: progress, | ||||
|                 }; | ||||
|               }, | ||||
|             ).future; | ||||
|         if (cloudFile == null) { | ||||
|           throw ArgumentError('Failed to upload the file...'); | ||||
|         } | ||||
|         final clone = List.of(attachments.value); | ||||
|         clone[index] = UniversalFile(data: cloudFile, type: attachment.type); | ||||
|         attachments.value = clone; | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         attachmentProgress.value = attachmentProgress.value..remove(index); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> 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; | ||||
|     } | ||||
|  | ||||
|     Future<void> performAction() async { | ||||
|       try { | ||||
|         submitting.value = true; | ||||
|  | ||||
|         await Future.wait( | ||||
|           attachments.value | ||||
|               .where((e) => e.isOnDevice) | ||||
|               .mapIndexed((idx, e) => uploadAttachment(idx)), | ||||
|         ); | ||||
|  | ||||
|         final client = ref.watch(apiClientProvider); | ||||
|         await client.request( | ||||
|           originalPost == null ? '/posts' : '/posts/${originalPost!.id}', | ||||
|           data: { | ||||
|             'title': titleController.text, | ||||
|             'description': descriptionController.text, | ||||
|             'content': contentController.text, | ||||
|             'visibility': | ||||
|                 visibility.value, // Add visibility field to API request | ||||
|             '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, | ||||
|           }, | ||||
|           options: Options( | ||||
|             headers: {'X-Pub': currentPublisher.value?.name}, | ||||
|             method: originalPost == null ? 'POST' : 'PATCH', | ||||
|           ), | ||||
|         ); | ||||
|         if (context.mounted) { | ||||
|           context.maybePop(true); | ||||
|         } | ||||
|       } catch (err) { | ||||
|         showErrorAlert(err); | ||||
|       } finally { | ||||
|         submitting.value = false; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Future<void> 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; | ||||
|  | ||||
|       if (isPaste && isModifierPressed) { | ||||
|         handlePaste(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void showVisibilityModal() { | ||||
|       showDialog( | ||||
|     void showSettingsSheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         isScrollControlled: true, | ||||
|         builder: | ||||
|             (context) => AlertDialog( | ||||
|               title: Text('postVisibility'.tr()), | ||||
|               content: Column( | ||||
|                 mainAxisSize: MainAxisSize.min, | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.public), | ||||
|                     title: Text('postVisibilityPublic'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 0; | ||||
|                       Navigator.pop(context); | ||||
|             (context) => ComposeSettingsSheet( | ||||
|               titleController: state.titleController, | ||||
|               descriptionController: state.descriptionController, | ||||
|               visibility: state.visibility, | ||||
|               onVisibilityChanged: () { | ||||
|                 // Trigger rebuild if needed | ||||
|               }, | ||||
|                     selected: visibility.value == 0, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.group), | ||||
|                     title: Text('postVisibilityFriends'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 1; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 1, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.link_off), | ||||
|                     title: Text('postVisibilityUnlisted'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 2; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 2, | ||||
|                   ), | ||||
|                   ListTile( | ||||
|                     leading: Icon(Symbols.lock), | ||||
|                     title: Text('postVisibilityPrivate'.tr()), | ||||
|                     onTap: () { | ||||
|                       visibility.value = 3; | ||||
|                       Navigator.pop(context); | ||||
|                     }, | ||||
|                     selected: visibility.value == 3, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // 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'; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     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: () { | ||||
|     void showKeyboardShortcutsDialog() { | ||||
|       showDialog( | ||||
|         context: context, | ||||
|         builder: | ||||
| @@ -367,13 +135,102 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|               ], | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildWideAttachmentGrid() { | ||||
|       return GridView.builder( | ||||
|         shrinkWrap: true, | ||||
|         physics: const NeverScrollableScrollPhysics(), | ||||
|         gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|           crossAxisCount: 3, | ||||
|           crossAxisSpacing: 8, | ||||
|           mainAxisSpacing: 8, | ||||
|         ), | ||||
|         itemCount: state.attachments.value.length, | ||||
|         itemBuilder: (context, idx) { | ||||
|           return AttachmentPreview( | ||||
|             item: state.attachments.value[idx], | ||||
|             progress: state.attachmentProgress.value[idx], | ||||
|             onRequestUpload: | ||||
|                 () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|             onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|             onMove: (delta) { | ||||
|               state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                 state.attachments.value, | ||||
|                 idx, | ||||
|                 delta, | ||||
|               ); | ||||
|             }, | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildNarrowAttachmentList() { | ||||
|       return Column( | ||||
|         children: [ | ||||
|           for (var idx = 0; idx < state.attachments.value.length; idx++) | ||||
|             Container( | ||||
|               margin: const EdgeInsets.only(bottom: 8), | ||||
|               child: AttachmentPreview( | ||||
|                 item: state.attachments.value[idx], | ||||
|                 progress: state.attachmentProgress.value[idx], | ||||
|                 onRequestUpload: | ||||
|                     () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|                 onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|                 onMove: (delta) { | ||||
|                   state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                     state.attachments.value, | ||||
|                     idx, | ||||
|                     delta, | ||||
|                   ); | ||||
|                 }, | ||||
|               ), | ||||
|             ), | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Build UI | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         title: | ||||
|             isWideScreen(context) | ||||
|                 ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr()) | ||||
|                 : null, | ||||
|         actions: [ | ||||
|           IconButton( | ||||
|             onPressed: submitting.value ? null : performAction, | ||||
|             icon: const Icon(Symbols.settings), | ||||
|             onPressed: showSettingsSheet, | ||||
|             tooltip: 'postSettings'.tr(), | ||||
|           ), | ||||
|           if (isWideScreen(context)) | ||||
|             Tooltip( | ||||
|               message: 'keyboard_shortcuts'.tr(), | ||||
|               child: IconButton( | ||||
|                 icon: const Icon(Symbols.keyboard), | ||||
|                 onPressed: showKeyboardShortcutsDialog, | ||||
|               ), | ||||
|             ), | ||||
|           ValueListenableBuilder<bool>( | ||||
|             valueListenable: state.submitting, | ||||
|             builder: (context, submitting, _) { | ||||
|               return IconButton( | ||||
|                 onPressed: | ||||
|                     submitting | ||||
|                         ? null | ||||
|                         : () => ComposeLogic.performAction( | ||||
|                           ref, | ||||
|                           state, | ||||
|                           context, | ||||
|                           originalPost: originalPost, | ||||
|                           repliedPost: repliedPost, | ||||
|                           forwardedPost: forwardedPost, | ||||
|                           postType: 0, // Regular post type | ||||
|                         ), | ||||
|                 icon: | ||||
|                 submitting.value | ||||
|                     submitting | ||||
|                         ? SizedBox( | ||||
|                           width: 28, | ||||
|                           height: 28, | ||||
| @@ -382,9 +239,11 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                             strokeWidth: 2.5, | ||||
|                           ), | ||||
|                         ).center() | ||||
|                     : originalPost != null | ||||
|                     ? const Icon(Symbols.edit) | ||||
|                     : const Icon(Symbols.upload), | ||||
|                         : Icon( | ||||
|                           originalPost != null ? Symbols.edit : Symbols.upload, | ||||
|                         ), | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|           const Gap(8), | ||||
|         ], | ||||
| @@ -392,59 +251,22 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|       body: Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           if (repliedPost != null) | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   const Icon(Symbols.reply, size: 16), | ||||
|                   const Gap(8), | ||||
|                   Expanded( | ||||
|                     child: Text( | ||||
|                       '${'reply'.tr()}: ${repliedPost!.publisher.nick}', | ||||
|                       style: Theme.of(context).textTheme.bodySmall, | ||||
|                       maxLines: 1, | ||||
|                       overflow: TextOverflow.ellipsis, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           if (forwardedPost != null) | ||||
|             Container( | ||||
|               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), | ||||
|               color: Theme.of( | ||||
|                 context, | ||||
|               ).colorScheme.surfaceVariant.withOpacity(0.5), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   const Icon(Symbols.forward, size: 16), | ||||
|                   const Gap(8), | ||||
|                   Expanded( | ||||
|                     child: Text( | ||||
|                       '${'forward'.tr()}: ${forwardedPost!.publisher.nick}', | ||||
|                       style: Theme.of(context).textTheme.bodySmall, | ||||
|                       maxLines: 1, | ||||
|                       overflow: TextOverflow.ellipsis, | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           // Reply/Forward info section | ||||
|           _buildInfoBanner(context), | ||||
|  | ||||
|           // Main content area | ||||
|           Expanded( | ||||
|             child: Row( | ||||
|               spacing: 12, | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 // Publisher profile picture | ||||
|                 GestureDetector( | ||||
|                   child: ProfilePictureWidget( | ||||
|                     fileId: currentPublisher.value?.picture?.id, | ||||
|                     fileId: state.currentPublisher.value?.picture?.id, | ||||
|                     radius: 20, | ||||
|                     fallbackIcon: | ||||
|                         currentPublisher.value == null | ||||
|                         state.currentPublisher.value == null | ||||
|                             ? Symbols.question_mark | ||||
|                             : null, | ||||
|                   ), | ||||
| @@ -452,93 +274,43 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                     showModalBottomSheet( | ||||
|                       isScrollControlled: true, | ||||
|                       context: context, | ||||
|                       builder: (context) => PublisherModal(), | ||||
|                       builder: (context) => const PublisherModal(), | ||||
|                     ).then((value) { | ||||
|                       if (value is SnPublisher) currentPublisher.value = value; | ||||
|                       if (value != null) { | ||||
|                         state.currentPublisher.value = value; | ||||
|                       } | ||||
|                     }); | ||||
|                   }, | ||||
|                 ).padding(top: 16), | ||||
|  | ||||
|                 // Post content form | ||||
|                 Expanded( | ||||
|                   child: SingleChildScrollView( | ||||
|                     padding: EdgeInsets.symmetric(vertical: 16), | ||||
|                     padding: const EdgeInsets.symmetric(vertical: 12), | ||||
|                     child: Column( | ||||
|                       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                       children: [ | ||||
|                         Row( | ||||
|                           children: [ | ||||
|                             OutlinedButton( | ||||
|                               onPressed: () { | ||||
|                                 showVisibilityModal(); | ||||
|                               }, | ||||
|                               style: OutlinedButton.styleFrom( | ||||
|                                 shape: RoundedRectangleBorder( | ||||
|                                   borderRadius: BorderRadius.circular(20), | ||||
|                                 ), | ||||
|                                 side: BorderSide( | ||||
|                                   color: Theme.of( | ||||
|                                     context, | ||||
|                                   ).colorScheme.primary.withOpacity(0.5), | ||||
|                                 ), | ||||
|                                 padding: EdgeInsets.symmetric(horizontal: 16), | ||||
|                                 visualDensity: const VisualDensity( | ||||
|                                   vertical: -2, | ||||
|                                   horizontal: -4, | ||||
|                                 ), | ||||
|                               ), | ||||
|                               child: Row( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   Icon( | ||||
|                                     getVisibilityIcon(visibility.value), | ||||
|                                     size: 16, | ||||
|                                     color: | ||||
|                                         Theme.of(context).colorScheme.primary, | ||||
|                                   ), | ||||
|                                   const SizedBox(width: 6), | ||||
|                                   Text( | ||||
|                                     getVisibilityText(visibility.value).tr(), | ||||
|                                     style: TextStyle( | ||||
|                                       fontSize: 14, | ||||
|                                       color: | ||||
|                                           Theme.of(context).colorScheme.primary, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ).padding(bottom: 6), | ||||
|                         TextField( | ||||
|                           controller: titleController, | ||||
|                           decoration: InputDecoration.collapsed( | ||||
|                             hintText: 'postTitle'.tr(), | ||||
|                           ), | ||||
|                           style: TextStyle(fontSize: 16), | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                         TextField( | ||||
|                           controller: descriptionController, | ||||
|                           decoration: InputDecoration.collapsed( | ||||
|                             hintText: 'postDescription'.tr(), | ||||
|                           ), | ||||
|                           style: TextStyle(fontSize: 16), | ||||
|                           onTapOutside: | ||||
|                               (_) => | ||||
|                                   FocusManager.instance.primaryFocus?.unfocus(), | ||||
|                         ), | ||||
|                         const Gap(8), | ||||
|                         // Content field with borderless design | ||||
|                         RawKeyboardListener( | ||||
|                           focusNode: FocusNode(), | ||||
|                           onKey: handleKeyPress, | ||||
|                           onKey: | ||||
|                               (event) => ComposeLogic.handleKeyPress( | ||||
|                                 event, | ||||
|                                 state, | ||||
|                                 ref, | ||||
|                                 context, | ||||
|                                 originalPost: originalPost, | ||||
|                                 repliedPost: repliedPost, | ||||
|                                 forwardedPost: forwardedPost, | ||||
|                                 postType: 0, // Regular post type | ||||
|                               ), | ||||
|                           child: TextField( | ||||
|                             controller: contentController, | ||||
|                             style: TextStyle(fontSize: 14), | ||||
|                             controller: state.contentController, | ||||
|                             style: theme.textTheme.bodyMedium, | ||||
|                             decoration: InputDecoration( | ||||
|                               border: InputBorder.none, | ||||
|                               hintText: 'postPlaceholder'.tr(), | ||||
|                               isDense: true, | ||||
|                               hintText: 'postContent'.tr(), | ||||
|                               contentPadding: const EdgeInsets.all(8), | ||||
|                             ), | ||||
|                             maxLines: null, | ||||
|                             onTapOutside: | ||||
| @@ -547,81 +319,16 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|                                         ?.unfocus(), | ||||
|                           ), | ||||
|                         ), | ||||
|  | ||||
|                         const Gap(8), | ||||
|  | ||||
|                         // Attachments preview | ||||
|                         LayoutBuilder( | ||||
|                           builder: (context, constraints) { | ||||
|                             final isWide = isWideScreen(context); | ||||
|                             return isWide | ||||
|                                 ? Wrap( | ||||
|                                   spacing: 8, | ||||
|                                   runSpacing: 8, | ||||
|                                   children: [ | ||||
|                                     for ( | ||||
|                                       var idx = 0; | ||||
|                                       idx < attachments.value.length; | ||||
|                                       idx++ | ||||
|                                     ) | ||||
|                                       SizedBox( | ||||
|                                         width: constraints.maxWidth / 2 - 4, | ||||
|                                         child: AttachmentPreview( | ||||
|                                           item: attachments.value[idx], | ||||
|                                           progress: | ||||
|                                               attachmentProgress.value[idx], | ||||
|                                           onRequestUpload: | ||||
|                                               () => uploadAttachment(idx), | ||||
|                                           onDelete: () => deleteAttachment(idx), | ||||
|                                           onMove: (delta) { | ||||
|                                             if (idx + delta < 0 || | ||||
|                                                 idx + delta >= | ||||
|                                                     attachments.value.length) { | ||||
|                                               return; | ||||
|                                             } | ||||
|                                             final clone = List.of( | ||||
|                                               attachments.value, | ||||
|                                             ); | ||||
|                                             clone.insert( | ||||
|                                               idx + delta, | ||||
|                                               clone.removeAt(idx), | ||||
|                                             ); | ||||
|                                             attachments.value = clone; | ||||
|                                           }, | ||||
|                                         ), | ||||
|                                       ), | ||||
|                                   ], | ||||
|                                 ) | ||||
|                                 : Column( | ||||
|                                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                   spacing: 8, | ||||
|                                   children: [ | ||||
|                                     for ( | ||||
|                                       var idx = 0; | ||||
|                                       idx < attachments.value.length; | ||||
|                                       idx++ | ||||
|                                     ) | ||||
|                                       AttachmentPreview( | ||||
|                                         item: attachments.value[idx], | ||||
|                                         progress: attachmentProgress.value[idx], | ||||
|                                         onRequestUpload: | ||||
|                                             () => uploadAttachment(idx), | ||||
|                                         onDelete: () => deleteAttachment(idx), | ||||
|                                         onMove: (delta) { | ||||
|                                           if (idx + delta < 0 || | ||||
|                                               idx + delta >= | ||||
|                                                   attachments.value.length) { | ||||
|                                             return; | ||||
|                                           } | ||||
|                                           final clone = List.of( | ||||
|                                             attachments.value, | ||||
|                                           ); | ||||
|                                           clone.insert( | ||||
|                                             idx + delta, | ||||
|                                             clone.removeAt(idx), | ||||
|                                           ); | ||||
|                                           attachments.value = clone; | ||||
|                                         }, | ||||
|                                       ), | ||||
|                                   ], | ||||
|                                 ); | ||||
|                                 ? buildWideAttachmentGrid() | ||||
|                                 : buildNarrowAttachmentList(); | ||||
|                           }, | ||||
|                         ), | ||||
|                       ], | ||||
| @@ -631,19 +338,21 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|               ], | ||||
|             ).padding(horizontal: 16), | ||||
|           ), | ||||
|  | ||||
|           // Bottom toolbar | ||||
|           Material( | ||||
|             elevation: 4, | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 IconButton( | ||||
|                   onPressed: pickPhotoMedia, | ||||
|                   onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), | ||||
|                   icon: const Icon(Symbols.add_a_photo), | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                   color: colorScheme.primary, | ||||
|                 ), | ||||
|                 IconButton( | ||||
|                   onPressed: pickVideoMedia, | ||||
|                   onPressed: () => ComposeLogic.pickVideoMedia(ref, state), | ||||
|                   icon: const Icon(Symbols.videocam), | ||||
|                   color: Theme.of(context).colorScheme.primary, | ||||
|                   color: colorScheme.primary, | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding( | ||||
| @@ -656,4 +365,37 @@ class PostComposeScreen extends HookConsumerWidget { | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Widget _buildInfoBanner(BuildContext context) { | ||||
|     if (originalPost != null) { | ||||
|       return Container( | ||||
|         width: double.infinity, | ||||
|         color: Theme.of(context).colorScheme.surfaceContainerHigh, | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Row( | ||||
|               children: [ | ||||
|                 Icon( | ||||
|                   repliedPost != null ? Symbols.reply : Symbols.forward, | ||||
|                   size: 16, | ||||
|                 ), | ||||
|                 const Gap(4), | ||||
|                 Text( | ||||
|                   repliedPost != null | ||||
|                       ? 'postReplyingTo'.tr() | ||||
|                       : 'postForwardingTo'.tr(), | ||||
|                   style: Theme.of(context).textTheme.labelMedium, | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|             const Gap(8), | ||||
|             PostItem(item: originalPost!, isOpenable: false), | ||||
|           ], | ||||
|         ).padding(all: 16), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return const SizedBox.shrink(); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										401
									
								
								lib/screens/posts/compose_article.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								lib/screens/posts/compose_article.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,401 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| 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/post.dart'; | ||||
| import 'package:island/screens/creators/publishers.dart'; | ||||
| import 'package:island/services/responsive.dart'; | ||||
|  | ||||
| import 'package:island/widgets/app_scaffold.dart'; | ||||
| import 'package:island/screens/posts/detail.dart'; | ||||
| import 'package:island/widgets/content/attachment_preview.dart'; | ||||
| import 'package:island/widgets/post/compose_shared.dart'; | ||||
| import 'package:island/widgets/post/publishers_modal.dart'; | ||||
| import 'package:island/widgets/content/cloud_files.dart'; | ||||
| import 'package:island/widgets/post/compose_settings_sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
| import 'package:styled_widget/styled_widget.dart'; | ||||
|  | ||||
| @RoutePage() | ||||
| class ArticleEditScreen extends HookConsumerWidget { | ||||
|   final String id; | ||||
|   const ArticleEditScreen({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) => 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), | ||||
|           ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| @RoutePage() | ||||
| 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), | ||||
|       [originalPost], | ||||
|     ); | ||||
|  | ||||
|     final showPreview = useState(false); | ||||
|  | ||||
|     // Initialize publisher once when data is available | ||||
|     useEffect(() { | ||||
|       if (publishers.value?.isNotEmpty ?? false) { | ||||
|         state.currentPublisher.value = publishers.value!.first; | ||||
|       } | ||||
|       return null; | ||||
|     }, [publishers]); | ||||
|  | ||||
|     // Dispose state when widget is disposed | ||||
|     useEffect(() { | ||||
|       return () => ComposeLogic.dispose(state); | ||||
|     }, []); | ||||
|  | ||||
|     // Helper methods | ||||
|     void showSettingsSheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         isScrollControlled: true, | ||||
|         builder: | ||||
|             (context) => ComposeSettingsSheet( | ||||
|               titleController: state.titleController, | ||||
|               descriptionController: state.descriptionController, | ||||
|               visibility: state.visibility, | ||||
|               onVisibilityChanged: () { | ||||
|                 // Trigger rebuild if needed | ||||
|               }, | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     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()}'), | ||||
|                   Text('Ctrl/Cmd + P: ${'toggle_preview'.tr()}'), | ||||
|                 ], | ||||
|               ), | ||||
|               actions: [ | ||||
|                 TextButton( | ||||
|                   onPressed: () => Navigator.of(context).pop(), | ||||
|                   child: Text('close'.tr()), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildPreviewPane() { | ||||
|       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: SingleChildScrollView( | ||||
|                 padding: const EdgeInsets.all(16), | ||||
|                 child: Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     if (state.titleController.text.isNotEmpty) ...[ | ||||
|                       Text( | ||||
|                         state.titleController.text, | ||||
|                         style: theme.textTheme.headlineSmall?.copyWith( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                     ], | ||||
|                     if (state.descriptionController.text.isNotEmpty) ...[ | ||||
|                       Text( | ||||
|                         state.descriptionController.text, | ||||
|                         style: theme.textTheme.bodyLarge?.copyWith( | ||||
|                           color: colorScheme.onSurface.withOpacity(0.7), | ||||
|                         ), | ||||
|                       ), | ||||
|                       const Gap(16), | ||||
|                     ], | ||||
|                     if (state.contentController.text.isNotEmpty) | ||||
|                       Text( | ||||
|                         state.contentController.text, | ||||
|                         style: theme.textTheme.bodyMedium, | ||||
|                       ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget buildEditorPane() { | ||||
|       return Column( | ||||
|         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|         children: [ | ||||
|           // Publisher row | ||||
|           Card( | ||||
|             elevation: 1, | ||||
|             child: Padding( | ||||
|               padding: const EdgeInsets.all(12), | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   GestureDetector( | ||||
|                     child: ProfilePictureWidget( | ||||
|                       fileId: state.currentPublisher.value?.picture?.id, | ||||
|                       radius: 20, | ||||
|                       fallbackIcon: | ||||
|                           state.currentPublisher.value == null | ||||
|                               ? Symbols.question_mark | ||||
|                               : null, | ||||
|                     ), | ||||
|                     onTap: () { | ||||
|                       showModalBottomSheet( | ||||
|                         isScrollControlled: true, | ||||
|                         context: context, | ||||
|                         builder: (context) => const PublisherModal(), | ||||
|                       ).then((value) { | ||||
|                         if (value != null) { | ||||
|                           state.currentPublisher.value = value; | ||||
|                         } | ||||
|                       }); | ||||
|                     }, | ||||
|                   ), | ||||
|                   const Gap(12), | ||||
|                   Text( | ||||
|                     state.currentPublisher.value?.name ?? | ||||
|                         'postPublisherUnselected'.tr(), | ||||
|                     style: theme.textTheme.bodyMedium, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           // Content field with keyboard listener | ||||
|           Expanded( | ||||
|             child: RawKeyboardListener( | ||||
|               focusNode: FocusNode(), | ||||
|               onKey: | ||||
|                   (event) => ComposeLogic.handleKeyPress( | ||||
|                     event, | ||||
|                     state, | ||||
|                     ref, | ||||
|                     context, | ||||
|                     originalPost: originalPost, | ||||
|                     postType: 1, // Article type | ||||
|                   ), | ||||
|               child: TextField( | ||||
|                 controller: state.contentController, | ||||
|                 style: theme.textTheme.bodyMedium, | ||||
|                 decoration: InputDecoration( | ||||
|                   border: InputBorder.none, | ||||
|                   hintText: 'postContent'.tr(), | ||||
|                   contentPadding: const EdgeInsets.all(8), | ||||
|                 ), | ||||
|                 maxLines: null, | ||||
|                 expands: true, | ||||
|                 textAlignVertical: TextAlignVertical.top, | ||||
|                 onTapOutside: | ||||
|                     (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           // Attachments preview | ||||
|           if (state.attachments.value.isNotEmpty) ...[ | ||||
|             const Gap(16), | ||||
|             Wrap( | ||||
|               spacing: 8, | ||||
|               runSpacing: 8, | ||||
|               children: [ | ||||
|                 for (var idx = 0; idx < state.attachments.value.length; idx++) | ||||
|                   SizedBox( | ||||
|                     width: 120, | ||||
|                     height: 120, | ||||
|                     child: AttachmentPreview( | ||||
|                       item: state.attachments.value[idx], | ||||
|                       progress: state.attachmentProgress.value[idx], | ||||
|                       onRequestUpload: | ||||
|                           () => ComposeLogic.uploadAttachment(ref, state, idx), | ||||
|                       onDelete: | ||||
|                           () => ComposeLogic.deleteAttachment(ref, state, idx), | ||||
|                       onMove: (delta) { | ||||
|                         state.attachments.value = ComposeLogic.moveAttachment( | ||||
|                           state.attachments.value, | ||||
|                           idx, | ||||
|                           delta, | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ), | ||||
|               ], | ||||
|             ), | ||||
|           ], | ||||
|         ], | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return AppScaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: const PageBackButton(), | ||||
|         actions: [ | ||||
|           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, | ||||
|             ), | ||||
|           ), | ||||
|           if (isWideScreen(context)) | ||||
|             Tooltip( | ||||
|               message: 'keyboard_shortcuts'.tr(), | ||||
|               child: IconButton( | ||||
|                 icon: const Icon(Symbols.keyboard), | ||||
|                 onPressed: showKeyboardShortcutsDialog, | ||||
|               ), | ||||
|             ), | ||||
|           ValueListenableBuilder<bool>( | ||||
|             valueListenable: state.submitting, | ||||
|             builder: (context, submitting, _) { | ||||
|               return IconButton( | ||||
|                 onPressed: | ||||
|                     submitting | ||||
|                         ? null | ||||
|                         : () => ComposeLogic.performAction( | ||||
|                           ref, | ||||
|                           state, | ||||
|                           context, | ||||
|                           originalPost: originalPost, | ||||
|                           postType: 1, // Article type | ||||
|                         ), | ||||
|                 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.all(16), | ||||
|               child: | ||||
|                   isWideScreen(context) | ||||
|                       ? Row( | ||||
|                         spacing: 16, | ||||
|                         children: [ | ||||
|                           Expanded( | ||||
|                             flex: showPreview.value ? 1 : 2, | ||||
|                             child: buildEditorPane(), | ||||
|                           ), | ||||
|                           if (showPreview.value) | ||||
|                             Expanded(child: buildPreviewPane()), | ||||
|                         ], | ||||
|                       ) | ||||
|                       : showPreview.value | ||||
|                       ? buildPreviewPane() | ||||
|                       : buildEditorPane(), | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           // Bottom toolbar | ||||
|           Material( | ||||
|             elevation: 4, | ||||
|             child: Row( | ||||
|               children: [ | ||||
|                 IconButton( | ||||
|                   onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), | ||||
|                   icon: const Icon(Symbols.add_a_photo), | ||||
|                   color: colorScheme.primary, | ||||
|                 ), | ||||
|                 IconButton( | ||||
|                   onPressed: () => ComposeLogic.pickVideoMedia(ref, state), | ||||
|                   icon: const Icon(Symbols.videocam), | ||||
|                   color: colorScheme.primary, | ||||
|                 ), | ||||
|               ], | ||||
|             ).padding( | ||||
|               bottom: MediaQuery.of(context).padding.bottom + 16, | ||||
|               horizontal: 16, | ||||
|               top: 8, | ||||
|             ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										178
									
								
								lib/widgets/post/compose_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								lib/widgets/post/compose_settings_sheet.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:island/widgets/content/sheet.dart'; | ||||
| import 'package:material_symbols_icons/symbols.dart'; | ||||
|  | ||||
| class ComposeSettingsSheet extends HookWidget { | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
|   final ValueNotifier<int> visibility; | ||||
|   final VoidCallback? onVisibilityChanged; | ||||
|  | ||||
|   const ComposeSettingsSheet({ | ||||
|     super.key, | ||||
|     required this.titleController, | ||||
|     required this.descriptionController, | ||||
|     required this.visibility, | ||||
|     this.onVisibilityChanged, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     final theme = Theme.of(context); | ||||
|     final colorScheme = theme.colorScheme; | ||||
|  | ||||
|     IconData getVisibilityIcon(int visibilityValue) { | ||||
|       switch (visibilityValue) { | ||||
|         case 1: | ||||
|           return Symbols.group; | ||||
|         case 2: | ||||
|           return Symbols.link_off; | ||||
|         case 3: | ||||
|           return Symbols.lock; | ||||
|         default: | ||||
|           return Symbols.public; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     String getVisibilityText(int visibilityValue) { | ||||
|       switch (visibilityValue) { | ||||
|         case 1: | ||||
|           return 'postVisibilityFriends'; | ||||
|         case 2: | ||||
|           return 'postVisibilityUnlisted'; | ||||
|         case 3: | ||||
|           return 'postVisibilityPrivate'; | ||||
|         default: | ||||
|           return 'postVisibilityPublic'; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget buildVisibilityOption( | ||||
|       BuildContext context, | ||||
|       int value, | ||||
|       IconData icon, | ||||
|       String textKey, | ||||
|     ) { | ||||
|       return ListTile( | ||||
|         leading: Icon(icon), | ||||
|         title: Text(textKey.tr()), | ||||
|         onTap: () { | ||||
|           visibility.value = value; | ||||
|           onVisibilityChanged?.call(); | ||||
|           Navigator.pop(context); | ||||
|         }, | ||||
|         selected: visibility.value == value, | ||||
|         contentPadding: const EdgeInsets.symmetric(horizontal: 20), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     void showVisibilitySheet() { | ||||
|       showModalBottomSheet( | ||||
|         context: context, | ||||
|         builder: (context) => SheetScaffold( | ||||
|           titleText: 'postVisibility'.tr(), | ||||
|           child: 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', | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return SheetScaffold( | ||||
|       titleText: 'postSettings'.tr(), | ||||
|       child: SingleChildScrollView( | ||||
|         padding: const EdgeInsets.all(16), | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             // Title field | ||||
|             TextField( | ||||
|               controller: titleController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'postTitle'.tr(), | ||||
|                 hintText: 'postTitle'.tr(), | ||||
|                 border: OutlineInputBorder( | ||||
|                   borderRadius: BorderRadius.circular(12), | ||||
|                 ), | ||||
|                 contentPadding: const EdgeInsets.all(16), | ||||
|               ), | ||||
|               style: theme.textTheme.titleLarge, | ||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const SizedBox(height: 16), | ||||
|  | ||||
|             // Description field | ||||
|             TextField( | ||||
|               controller: descriptionController, | ||||
|               decoration: InputDecoration( | ||||
|                 labelText: 'postDescription'.tr(), | ||||
|                 hintText: 'postDescription'.tr(), | ||||
|                 border: OutlineInputBorder( | ||||
|                   borderRadius: BorderRadius.circular(12), | ||||
|                 ), | ||||
|                 contentPadding: const EdgeInsets.all(16), | ||||
|               ), | ||||
|               style: theme.textTheme.bodyLarge, | ||||
|               maxLines: 3, | ||||
|               onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), | ||||
|             ), | ||||
|             const SizedBox(height: 24), | ||||
|  | ||||
|             // Visibility setting | ||||
|             Container( | ||||
|               decoration: BoxDecoration( | ||||
|                 border: Border.all( | ||||
|                   color: colorScheme.outline, | ||||
|                   width: 1, | ||||
|                 ), | ||||
|                 borderRadius: BorderRadius.circular(12), | ||||
|               ), | ||||
|               child: ListTile( | ||||
|                 leading: Icon(getVisibilityIcon(visibility.value)), | ||||
|                 title: Text('postVisibility'.tr()), | ||||
|                 subtitle: Text(getVisibilityText(visibility.value).tr()), | ||||
|                 trailing: const Icon(Symbols.chevron_right), | ||||
|                 onTap: showVisibilitySheet, | ||||
|                 shape: RoundedRectangleBorder( | ||||
|                   borderRadius: BorderRadius.circular(12), | ||||
|                 ), | ||||
|                 contentPadding: const EdgeInsets.symmetric( | ||||
|                   horizontal: 16, | ||||
|                   vertical: 8, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										308
									
								
								lib/widgets/post/compose_shared.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										308
									
								
								lib/widgets/post/compose_shared.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,308 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.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/services/file.dart'; | ||||
| import 'package:island/widgets/alert.dart'; | ||||
| import 'package:pasteboard/pasteboard.dart'; | ||||
|  | ||||
| class ComposeState { | ||||
|   final ValueNotifier<List<UniversalFile>> attachments; | ||||
|   final TextEditingController titleController; | ||||
|   final TextEditingController descriptionController; | ||||
|   final TextEditingController contentController; | ||||
|   final ValueNotifier<int> visibility; | ||||
|   final ValueNotifier<bool> submitting; | ||||
|   final ValueNotifier<Map<int, double>> attachmentProgress; | ||||
|   final ValueNotifier<SnPublisher?> currentPublisher; | ||||
|  | ||||
|   ComposeState({ | ||||
|     required this.attachments, | ||||
|     required this.titleController, | ||||
|     required this.descriptionController, | ||||
|     required this.contentController, | ||||
|     required this.visibility, | ||||
|     required this.submitting, | ||||
|     required this.attachmentProgress, | ||||
|     required this.currentPublisher, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| class ComposeLogic { | ||||
|   static ComposeState createState({ | ||||
|     SnPost? originalPost, | ||||
|     SnPost? forwardedPost, | ||||
|   }) { | ||||
|     return ComposeState( | ||||
|       attachments: ValueNotifier<List<UniversalFile>>( | ||||
|         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() ?? | ||||
|             [], | ||||
|       ), | ||||
|       titleController: TextEditingController(text: originalPost?.title), | ||||
|       descriptionController: TextEditingController( | ||||
|         text: originalPost?.description, | ||||
|       ), | ||||
|       contentController: TextEditingController( | ||||
|         text: | ||||
|             originalPost?.content ?? | ||||
|             (forwardedPost != null ? '> ${forwardedPost.content}\n\n' : null), | ||||
|       ), | ||||
|       visibility: ValueNotifier<int>(originalPost?.visibility ?? 0), | ||||
|       submitting: ValueNotifier<bool>(false), | ||||
|       attachmentProgress: ValueNotifier<Map<int, double>>({}), | ||||
|       currentPublisher: ValueNotifier<SnPublisher?>(null), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   static String getMimeTypeFromFileType(UniversalFileType type) { | ||||
|     return switch (type) { | ||||
|       UniversalFileType.image => 'image/unknown', | ||||
|       UniversalFileType.video => 'video/unknown', | ||||
|       UniversalFileType.audio => 'audio/unknown', | ||||
|       UniversalFileType.file => 'application/octet-stream', | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async { | ||||
|     final result = await ref | ||||
|         .watch(imagePickerProvider) | ||||
|         .pickMultiImage(requestFullMetadata: true); | ||||
|     if (result.isEmpty) return; | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       ...result.map( | ||||
|         (e) => UniversalFile(data: e, type: UniversalFileType.image), | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> pickVideoMedia(WidgetRef ref, ComposeState state) async { | ||||
|     final result = await ref | ||||
|         .watch(imagePickerProvider) | ||||
|         .pickVideo(source: ImageSource.gallery); | ||||
|     if (result == null) return; | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       UniversalFile(data: result, type: UniversalFileType.video), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static Future<void> uploadAttachment( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     int index, | ||||
|   ) async { | ||||
|     final attachment = state.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 | ||||
|       state.attachmentProgress.value = { | ||||
|         ...state.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, _) { | ||||
|               state.attachmentProgress.value = { | ||||
|                 ...state.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(state.attachments.value); | ||||
|       clone[index] = UniversalFile(data: cloudFile, type: attachment.type); | ||||
|       state.attachments.value = clone; | ||||
|     } catch (err) { | ||||
|       showErrorAlert(err); | ||||
|     } finally { | ||||
|       // Clean up progress state | ||||
|       state.attachmentProgress.value = {...state.attachmentProgress.value} | ||||
|         ..remove(index); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static List<UniversalFile> moveAttachment( | ||||
|     List<UniversalFile> 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; | ||||
|   } | ||||
|  | ||||
|   static Future<void> deleteAttachment( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     int index, | ||||
|   ) async { | ||||
|     final attachment = state.attachments.value[index]; | ||||
|     if (attachment.isOnCloud) { | ||||
|       final client = ref.watch(apiClientProvider); | ||||
|       await client.delete('/files/${attachment.data.id}'); | ||||
|     } | ||||
|     final clone = List.of(state.attachments.value); | ||||
|     clone.removeAt(index); | ||||
|     state.attachments.value = clone; | ||||
|   } | ||||
|  | ||||
|   static Future<void> performAction( | ||||
|     WidgetRef ref, | ||||
|     ComposeState state, | ||||
|     BuildContext context, { | ||||
|     SnPost? originalPost, | ||||
|     SnPost? repliedPost, | ||||
|     SnPost? forwardedPost, | ||||
|     int? postType, // 0 for regular post, 1 for article | ||||
|   }) async { | ||||
|     if (state.submitting.value) return; | ||||
|  | ||||
|     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) => uploadAttachment(ref, state, 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': state.titleController.text, | ||||
|         'description': state.descriptionController.text, | ||||
|         'content': state.contentController.text, | ||||
|         'visibility': state.visibility.value, | ||||
|         'attachments': | ||||
|             state.attachments.value | ||||
|                 .where((e) => e.isOnCloud) | ||||
|                 .map((e) => e.data.id) | ||||
|                 .toList(), | ||||
|         if (postType != null) 'type': postType, | ||||
|         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': state.currentPublisher.value?.name}, | ||||
|           method: isNewPost ? 'POST' : 'PATCH', | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       if (context.mounted) { | ||||
|         Navigator.of(context).maybePop(true); | ||||
|       } | ||||
|     } catch (err) { | ||||
|       showErrorAlert(err); | ||||
|     } finally { | ||||
|       state.submitting.value = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static Future<void> handlePaste(ComposeState state) async { | ||||
|     final clipboard = await Pasteboard.image; | ||||
|     if (clipboard == null) return; | ||||
|  | ||||
|     state.attachments.value = [ | ||||
|       ...state.attachments.value, | ||||
|       UniversalFile( | ||||
|         data: XFile.fromData(clipboard, mimeType: "image/jpeg"), | ||||
|         type: UniversalFileType.image, | ||||
|       ), | ||||
|     ]; | ||||
|   } | ||||
|  | ||||
|   static void handleKeyPress( | ||||
|     RawKeyEvent event, | ||||
|     ComposeState state, | ||||
|     WidgetRef ref, | ||||
|     BuildContext context, { | ||||
|     SnPost? originalPost, | ||||
|     SnPost? repliedPost, | ||||
|     SnPost? forwardedPost, | ||||
|     int? postType, | ||||
|   }) { | ||||
|     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(state); | ||||
|     } else if (isSubmit && isModifierPressed && !state.submitting.value) { | ||||
|       performAction( | ||||
|         ref, | ||||
|         state, | ||||
|         context, | ||||
|         originalPost: originalPost, | ||||
|         repliedPost: repliedPost, | ||||
|         forwardedPost: forwardedPost, | ||||
|         postType: postType, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static void dispose(ComposeState state) { | ||||
|     state.titleController.dispose(); | ||||
|     state.descriptionController.dispose(); | ||||
|     state.contentController.dispose(); | ||||
|     state.attachments.dispose(); | ||||
|     state.visibility.dispose(); | ||||
|     state.submitting.dispose(); | ||||
|     state.attachmentProgress.dispose(); | ||||
|     state.currentPublisher.dispose(); | ||||
|   } | ||||
| } | ||||
| @@ -53,7 +53,7 @@ enum PostItemType { | ||||
|   regular, | ||||
|  | ||||
|   /// Creator view with analytics and metadata | ||||
|   creator | ||||
|   creator, | ||||
| } | ||||
|  | ||||
| class SliverPostList extends HookConsumerWidget { | ||||
| @@ -93,10 +93,7 @@ class SliverPostList extends HookConsumerWidget { | ||||
|               final post = data.items[index]; | ||||
|  | ||||
|               return Column( | ||||
|                 children: [ | ||||
|                   _buildPostItem(post), | ||||
|                   const Divider(height: 1), | ||||
|                 ], | ||||
|                 children: [_buildPostItem(post), const Divider(height: 1)], | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
| @@ -115,7 +112,6 @@ class SliverPostList extends HookConsumerWidget { | ||||
|           onUpdate: onUpdate, | ||||
|         ); | ||||
|       case PostItemType.regular: | ||||
|       default: | ||||
|         return PostItem(item: post); | ||||
|     } | ||||
|   } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user