Compare commits
	
		
			2 Commits
		
	
	
		
			52111c4b95
			...
			89fd80bcb8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 89fd80bcb8 | |||
| ab4f4faafe | 
| @@ -337,6 +337,9 @@ | |||||||
|   "unauthorized": "Unauthorized", |   "unauthorized": "Unauthorized", | ||||||
|   "unauthorizedHint": "You're not signed in or session expired, please sign in again.", |   "unauthorizedHint": "You're not signed in or session expired, please sign in again.", | ||||||
|   "publisherBelongsTo": "Belongs to {}", |   "publisherBelongsTo": "Belongs to {}", | ||||||
|  |   "postContent": "Content", | ||||||
|  |   "postSettings": "Settings", | ||||||
|  |   "postPublisherUnselected": "Publisher Unspecified", | ||||||
|   "postVisibility": "Visibility", |   "postVisibility": "Visibility", | ||||||
|   "postVisibilityPublic": "Public", |   "postVisibilityPublic": "Public", | ||||||
|   "postVisibilityFriends": "Friends Only", |   "postVisibilityFriends": "Friends Only", | ||||||
| @@ -449,5 +452,7 @@ | |||||||
|   "checkInResultT4": "Best", |   "checkInResultT4": "Best", | ||||||
|   "accountProfileView": "View Profile", |   "accountProfileView": "View Profile", | ||||||
|   "unspecified": "Unspecified", |   "unspecified": "Unspecified", | ||||||
|   "added": "Added" |   "added": "Added", | ||||||
|  |   "preview": "Preview", | ||||||
|  |   "togglePreview": "Toggle Preview" | ||||||
| } | } | ||||||
|   | |||||||
| @@ -140,6 +140,8 @@ PODS: | |||||||
|     - nanopb/encode (= 3.30910.0) |     - nanopb/encode (= 3.30910.0) | ||||||
|   - nanopb/decode (3.30910.0) |   - nanopb/decode (3.30910.0) | ||||||
|   - nanopb/encode (3.30910.0) |   - nanopb/encode (3.30910.0) | ||||||
|  |   - native_exif (0.0.1): | ||||||
|  |     - Flutter | ||||||
|   - OrderedSet (6.0.3) |   - OrderedSet (6.0.3) | ||||||
|   - package_info_plus (0.4.5): |   - package_info_plus (0.4.5): | ||||||
|     - Flutter |     - Flutter | ||||||
| @@ -218,6 +220,7 @@ DEPENDENCIES: | |||||||
|   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) |   - livekit_client (from `.symlinks/plugins/livekit_client/ios`) | ||||||
|   - media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/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`) |   - 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`) |   - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) | ||||||
|   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) |   - pasteboard (from `.symlinks/plugins/pasteboard/ios`) | ||||||
|   - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) |   - 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" |     :path: ".symlinks/plugins/media_kit_libs_ios_video/ios" | ||||||
|   media_kit_video: |   media_kit_video: | ||||||
|     :path: ".symlinks/plugins/media_kit_video/ios" |     :path: ".symlinks/plugins/media_kit_video/ios" | ||||||
|  |   native_exif: | ||||||
|  |     :path: ".symlinks/plugins/native_exif/ios" | ||||||
|   package_info_plus: |   package_info_plus: | ||||||
|     :path: ".symlinks/plugins/package_info_plus/ios" |     :path: ".symlinks/plugins/package_info_plus/ios" | ||||||
|   pasteboard: |   pasteboard: | ||||||
| @@ -349,6 +354,7 @@ SPEC CHECKSUMS: | |||||||
|   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 |   media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 | ||||||
|   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 |   media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 | ||||||
|   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 |   nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 | ||||||
|  |   native_exif: 0eb73d3d5b3ca892719228df8d2d1b13d1ae396c | ||||||
|   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 |   OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 | ||||||
|   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 |   package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 | ||||||
|   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c |   pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c | ||||||
|   | |||||||
| @@ -8,14 +8,14 @@ class AppRouter extends RootStackRouter { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   List<AutoRoute> get routes => [ |   List<AutoRoute> get routes => [ | ||||||
|  |     AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'), | ||||||
|  |     AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'), | ||||||
|     AutoRoute( |     AutoRoute( | ||||||
|       page: ExploreShellRoute.page, |       page: ExploreShellRoute.page, | ||||||
|       path: '/', |       path: '/', | ||||||
|       children: [ |       children: [ | ||||||
|         AutoRoute(page: ExploreRoute.page, path: ''), |         AutoRoute(page: ExploreRoute.page, path: ''), | ||||||
|         AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'), |  | ||||||
|         AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'), |         AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'), | ||||||
|         AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'), |  | ||||||
|         AutoRoute(page: PublisherProfileRoute.page, path: 'publishers/:name'), |         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:auto_route/auto_route.dart'; | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||||
| import 'package:island/widgets/app_scaffold.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:island/widgets/post/post_list.dart'; | ||||||
|  | import 'package:material_symbols_icons/symbols.dart'; | ||||||
|  |  | ||||||
| @RoutePage() | @RoutePage() | ||||||
| class CreatorPostListScreen extends HookConsumerWidget { | class CreatorPostListScreen extends HookConsumerWidget { | ||||||
| @@ -15,13 +18,62 @@ class CreatorPostListScreen extends HookConsumerWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   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( |     return AppScaffold( | ||||||
|       appBar: AppBar(title: Text('posts').tr()), |       appBar: AppBar(title: Text('posts').tr()), | ||||||
|       body: CustomScrollView( |       body: CustomScrollView( | ||||||
|  |         key: ValueKey(refreshKey.value), | ||||||
|         slivers: [ |         slivers: [ | ||||||
|           SliverPostList(pubName: pubName, itemType: PostItemType.creator), |           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:auto_route/auto_route.dart'; | ||||||
| import 'package:collection/collection.dart'; |  | ||||||
| import 'package:dio/dio.dart'; |  | ||||||
| import 'package:easy_localization/easy_localization.dart'; | import 'package:easy_localization/easy_localization.dart'; | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter/services.dart'; |  | ||||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | import 'package:flutter_hooks/flutter_hooks.dart'; | ||||||
| import 'package:gap/gap.dart'; | import 'package:gap/gap.dart'; | ||||||
| import 'package:hooks_riverpod/hooks_riverpod.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/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/creators/publishers.dart'; | ||||||
| import 'package:island/screens/posts/detail.dart'; | import 'package:island/screens/posts/compose_article.dart'; | ||||||
| import 'package:island/services/file.dart'; |  | ||||||
| import 'package:island/services/responsive.dart'; | import 'package:island/services/responsive.dart'; | ||||||
| import 'package:island/widgets/alert.dart'; |  | ||||||
| import 'package:island/widgets/app_scaffold.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/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/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:material_symbols_icons/symbols.dart'; | ||||||
| import 'package:pasteboard/pasteboard.dart'; |  | ||||||
| import 'package:styled_widget/styled_widget.dart'; | import 'package:styled_widget/styled_widget.dart'; | ||||||
|  |  | ||||||
| @RoutePage() | @RoutePage() | ||||||
| @@ -54,282 +48,150 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|   final SnPost? originalPost; |   final SnPost? originalPost; | ||||||
|   final SnPost? repliedPost; |   final SnPost? repliedPost; | ||||||
|   final SnPost? forwardedPost; |   final SnPost? forwardedPost; | ||||||
|  |   final int? type; | ||||||
|   const PostComposeScreen({ |   const PostComposeScreen({ | ||||||
|     super.key, |     super.key, | ||||||
|     this.originalPost, |     this.originalPost, | ||||||
|     this.repliedPost, |     this.repliedPost, | ||||||
|     this.forwardedPost, |     this.forwardedPost, | ||||||
|  |     @QueryParam('type') this.type, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context, WidgetRef ref) { |   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 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(() { |     useEffect(() { | ||||||
|       if (publishers.value?.isNotEmpty ?? false) { |       if (publishers.value?.isNotEmpty ?? false) { | ||||||
|         currentPublisher.value = publishers.value!.first; |         state.currentPublisher.value = publishers.value!.first; | ||||||
|       } |       } | ||||||
|       return null; |       return null; | ||||||
|     }, [publishers]); |     }, [publishers]); | ||||||
|  |  | ||||||
|     // Contains the XFile, ByteData, or SnCloudFile |     // Dispose state when widget is disposed | ||||||
|     final attachments = useState<List<UniversalFile>>( |     useEffect(() { | ||||||
|       originalPost?.attachments |       return () => ComposeLogic.dispose(state); | ||||||
|               .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), |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     // Add visibility state with default value from original post or 0 (public) |     // Helper methods | ||||||
|     final visibility = useState<int>(originalPost?.visibility ?? 0); |  | ||||||
|  |  | ||||||
|     final submitting = useState(false); |     void showSettingsSheet() { | ||||||
|  |       showModalBottomSheet( | ||||||
|     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( |  | ||||||
|         context: context, |         context: context, | ||||||
|  |         isScrollControlled: true, | ||||||
|         builder: |         builder: | ||||||
|             (context) => AlertDialog( |             (context) => ComposeSettingsSheet( | ||||||
|               title: Text('postVisibility'.tr()), |               titleController: state.titleController, | ||||||
|               content: Column( |               descriptionController: state.descriptionController, | ||||||
|                 mainAxisSize: MainAxisSize.min, |               visibility: state.visibility, | ||||||
|                 children: [ |               onVisibilityChanged: () { | ||||||
|                   ListTile( |                 // Trigger rebuild if needed | ||||||
|                     leading: Icon(Symbols.public), |               }, | ||||||
|                     title: Text('postVisibilityPublic'.tr()), |  | ||||||
|                     onTap: () { |  | ||||||
|                       visibility.value = 0; |  | ||||||
|                       Navigator.pop(context); |  | ||||||
|                     }, |  | ||||||
|                     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 |     void showKeyboardShortcutsDialog() { | ||||||
|     IconData getVisibilityIcon(int visibilityValue) { |       showDialog( | ||||||
|       switch (visibilityValue) { |         context: context, | ||||||
|         case 1: // Friends |         builder: | ||||||
|           return Symbols.group; |             (context) => AlertDialog( | ||||||
|         case 2: // Unlisted |               title: Text('keyboard_shortcuts'.tr()), | ||||||
|           return Symbols.link_off; |               content: Column( | ||||||
|         case 3: // Private |                 mainAxisSize: MainAxisSize.min, | ||||||
|           return Symbols.lock; |                 crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|         default: // Public (0) or unknown |                 children: [ | ||||||
|           return Symbols.public; |                   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 get the translation key for each visibility status |     Widget buildWideAttachmentGrid() { | ||||||
|     String getVisibilityText(int visibilityValue) { |       return GridView.builder( | ||||||
|       switch (visibilityValue) { |         shrinkWrap: true, | ||||||
|         case 1: // Friends |         physics: const NeverScrollableScrollPhysics(), | ||||||
|           return 'postVisibilityFriends'; |         gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||||
|         case 2: // Unlisted |           crossAxisCount: 3, | ||||||
|           return 'postVisibilityUnlisted'; |           crossAxisSpacing: 8, | ||||||
|         case 3: // Private |           mainAxisSpacing: 8, | ||||||
|           return 'postVisibilityPrivate'; |         ), | ||||||
|         default: // Public (0) or unknown |         itemCount: state.attachments.value.length, | ||||||
|           return 'postVisibilityPublic'; |         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( |     return AppScaffold( | ||||||
|       appBar: AppBar( |       appBar: AppBar( | ||||||
|         leading: const PageBackButton(), |         leading: const PageBackButton(), | ||||||
| @@ -338,53 +200,50 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|                 ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr()) |                 ? Text(originalPost != null ? 'editPost'.tr() : 'newPost'.tr()) | ||||||
|                 : null, |                 : null, | ||||||
|         actions: [ |         actions: [ | ||||||
|  |           IconButton( | ||||||
|  |             icon: const Icon(Symbols.settings), | ||||||
|  |             onPressed: showSettingsSheet, | ||||||
|  |             tooltip: 'postSettings'.tr(), | ||||||
|  |           ), | ||||||
|           if (isWideScreen(context)) |           if (isWideScreen(context)) | ||||||
|             Tooltip( |             Tooltip( | ||||||
|               message: 'keyboard_shortcuts'.tr(), |               message: 'keyboard_shortcuts'.tr(), | ||||||
|               child: IconButton( |               child: IconButton( | ||||||
|                 icon: const Icon(Symbols.keyboard), |                 icon: const Icon(Symbols.keyboard), | ||||||
|                 onPressed: () { |                 onPressed: 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()), |  | ||||||
|                             ), |  | ||||||
|                           ], |  | ||||||
|                         ), |  | ||||||
|                   ); |  | ||||||
|                 }, |  | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|           IconButton( |           ValueListenableBuilder<bool>( | ||||||
|             onPressed: submitting.value ? null : performAction, |             valueListenable: state.submitting, | ||||||
|             icon: |             builder: (context, submitting, _) { | ||||||
|                 submitting.value |               return IconButton( | ||||||
|                     ? SizedBox( |                 onPressed: | ||||||
|                       width: 28, |                     submitting | ||||||
|                       height: 28, |                         ? null | ||||||
|                       child: const CircularProgressIndicator( |                         : () => ComposeLogic.performAction( | ||||||
|                         color: Colors.white, |                           ref, | ||||||
|                         strokeWidth: 2.5, |                           state, | ||||||
|                       ), |                           context, | ||||||
|                     ).center() |                           originalPost: originalPost, | ||||||
|                     : originalPost != null |                           repliedPost: repliedPost, | ||||||
|                     ? const Icon(Symbols.edit) |                           forwardedPost: forwardedPost, | ||||||
|                     : const Icon(Symbols.upload), |                           postType: 0, // Regular post 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), |           const Gap(8), | ||||||
|         ], |         ], | ||||||
| @@ -392,59 +251,22 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|       body: Column( |       body: Column( | ||||||
|         crossAxisAlignment: CrossAxisAlignment.start, |         crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|         children: [ |         children: [ | ||||||
|           if (repliedPost != null) |           // Reply/Forward info section | ||||||
|             Container( |           _buildInfoBanner(context), | ||||||
|               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |  | ||||||
|               color: Theme.of( |           // Main content area | ||||||
|                 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, |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                 ], |  | ||||||
|               ), |  | ||||||
|             ), |  | ||||||
|           Expanded( |           Expanded( | ||||||
|             child: Row( |             child: Row( | ||||||
|               spacing: 12, |               spacing: 12, | ||||||
|               crossAxisAlignment: CrossAxisAlignment.start, |               crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|               children: [ |               children: [ | ||||||
|  |                 // Publisher profile picture | ||||||
|                 GestureDetector( |                 GestureDetector( | ||||||
|                   child: ProfilePictureWidget( |                   child: ProfilePictureWidget( | ||||||
|                     fileId: currentPublisher.value?.picture?.id, |                     fileId: state.currentPublisher.value?.picture?.id, | ||||||
|                     radius: 20, |                     radius: 20, | ||||||
|                     fallbackIcon: |                     fallbackIcon: | ||||||
|                         currentPublisher.value == null |                         state.currentPublisher.value == null | ||||||
|                             ? Symbols.question_mark |                             ? Symbols.question_mark | ||||||
|                             : null, |                             : null, | ||||||
|                   ), |                   ), | ||||||
| @@ -452,93 +274,43 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|                     showModalBottomSheet( |                     showModalBottomSheet( | ||||||
|                       isScrollControlled: true, |                       isScrollControlled: true, | ||||||
|                       context: context, |                       context: context, | ||||||
|                       builder: (context) => PublisherModal(), |                       builder: (context) => const PublisherModal(), | ||||||
|                     ).then((value) { |                     ).then((value) { | ||||||
|                       if (value is SnPublisher) currentPublisher.value = value; |                       if (value != null) { | ||||||
|  |                         state.currentPublisher.value = value; | ||||||
|  |                       } | ||||||
|                     }); |                     }); | ||||||
|                   }, |                   }, | ||||||
|                 ).padding(top: 16), |                 ).padding(top: 16), | ||||||
|  |  | ||||||
|  |                 // Post content form | ||||||
|                 Expanded( |                 Expanded( | ||||||
|                   child: SingleChildScrollView( |                   child: SingleChildScrollView( | ||||||
|                     padding: EdgeInsets.symmetric(vertical: 16), |                     padding: const EdgeInsets.symmetric(vertical: 12), | ||||||
|                     child: Column( |                     child: Column( | ||||||
|                       crossAxisAlignment: CrossAxisAlignment.start, |                       crossAxisAlignment: CrossAxisAlignment.start, | ||||||
|                       children: [ |                       children: [ | ||||||
|                         Row( |                         // Content field with borderless design | ||||||
|                           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), |  | ||||||
|                         RawKeyboardListener( |                         RawKeyboardListener( | ||||||
|                           focusNode: FocusNode(), |                           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( |                           child: TextField( | ||||||
|                             controller: contentController, |                             controller: state.contentController, | ||||||
|                             style: TextStyle(fontSize: 14), |                             style: theme.textTheme.bodyMedium, | ||||||
|                             decoration: InputDecoration( |                             decoration: InputDecoration( | ||||||
|                               border: InputBorder.none, |                               border: InputBorder.none, | ||||||
|                               hintText: 'postPlaceholder'.tr(), |                               hintText: 'postContent'.tr(), | ||||||
|                               isDense: true, |                               contentPadding: const EdgeInsets.all(8), | ||||||
|                             ), |                             ), | ||||||
|                             maxLines: null, |                             maxLines: null, | ||||||
|                             onTapOutside: |                             onTapOutside: | ||||||
| @@ -547,81 +319,16 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|                                         ?.unfocus(), |                                         ?.unfocus(), | ||||||
|                           ), |                           ), | ||||||
|                         ), |                         ), | ||||||
|  |  | ||||||
|                         const Gap(8), |                         const Gap(8), | ||||||
|  |  | ||||||
|  |                         // Attachments preview | ||||||
|                         LayoutBuilder( |                         LayoutBuilder( | ||||||
|                           builder: (context, constraints) { |                           builder: (context, constraints) { | ||||||
|                             final isWide = isWideScreen(context); |                             final isWide = isWideScreen(context); | ||||||
|                             return isWide |                             return isWide | ||||||
|                                 ? Wrap( |                                 ? buildWideAttachmentGrid() | ||||||
|                                   spacing: 8, |                                 : buildNarrowAttachmentList(); | ||||||
|                                   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; |  | ||||||
|                                         }, |  | ||||||
|                                       ), |  | ||||||
|                                   ], |  | ||||||
|                                 ); |  | ||||||
|                           }, |                           }, | ||||||
|                         ), |                         ), | ||||||
|                       ], |                       ], | ||||||
| @@ -631,19 +338,21 @@ class PostComposeScreen extends HookConsumerWidget { | |||||||
|               ], |               ], | ||||||
|             ).padding(horizontal: 16), |             ).padding(horizontal: 16), | ||||||
|           ), |           ), | ||||||
|  |  | ||||||
|  |           // Bottom toolbar | ||||||
|           Material( |           Material( | ||||||
|             elevation: 4, |             elevation: 4, | ||||||
|             child: Row( |             child: Row( | ||||||
|               children: [ |               children: [ | ||||||
|                 IconButton( |                 IconButton( | ||||||
|                   onPressed: pickPhotoMedia, |                   onPressed: () => ComposeLogic.pickPhotoMedia(ref, state), | ||||||
|                   icon: const Icon(Symbols.add_a_photo), |                   icon: const Icon(Symbols.add_a_photo), | ||||||
|                   color: Theme.of(context).colorScheme.primary, |                   color: colorScheme.primary, | ||||||
|                 ), |                 ), | ||||||
|                 IconButton( |                 IconButton( | ||||||
|                   onPressed: pickVideoMedia, |                   onPressed: () => ComposeLogic.pickVideoMedia(ref, state), | ||||||
|                   icon: const Icon(Symbols.videocam), |                   icon: const Icon(Symbols.videocam), | ||||||
|                   color: Theme.of(context).colorScheme.primary, |                   color: colorScheme.primary, | ||||||
|                 ), |                 ), | ||||||
|               ], |               ], | ||||||
|             ).padding( |             ).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, |   regular, | ||||||
|  |  | ||||||
|   /// Creator view with analytics and metadata |   /// Creator view with analytics and metadata | ||||||
|   creator |   creator, | ||||||
| } | } | ||||||
|  |  | ||||||
| class SliverPostList extends HookConsumerWidget { | class SliverPostList extends HookConsumerWidget { | ||||||
| @@ -93,10 +93,7 @@ class SliverPostList extends HookConsumerWidget { | |||||||
|               final post = data.items[index]; |               final post = data.items[index]; | ||||||
|  |  | ||||||
|               return Column( |               return Column( | ||||||
|                 children: [ |                 children: [_buildPostItem(post), const Divider(height: 1)], | ||||||
|                   _buildPostItem(post), |  | ||||||
|                   const Divider(height: 1), |  | ||||||
|                 ], |  | ||||||
|               ); |               ); | ||||||
|             }, |             }, | ||||||
|           ), |           ), | ||||||
| @@ -115,7 +112,6 @@ class SliverPostList extends HookConsumerWidget { | |||||||
|           onUpdate: onUpdate, |           onUpdate: onUpdate, | ||||||
|         ); |         ); | ||||||
|       case PostItemType.regular: |       case PostItemType.regular: | ||||||
|       default: |  | ||||||
|         return PostItem(item: post); |         return PostItem(item: post); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user