From ab4f4faafec80af51ea22a3717bf8f3b4d0e5b61 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Thu, 19 Jun 2025 00:13:36 +0800 Subject: [PATCH] :art: Optimized code of post compose --- lib/screens/posts/compose.dart | 522 ++++++++++++++++++-------------- lib/widgets/post/post_list.dart | 20 +- 2 files changed, 298 insertions(+), 244 deletions(-) diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index 2d87525..cef2760 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -63,10 +63,14 @@ class PostComposeScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final publishers = ref.watch(publishersManagedProvider); + // Extract common theme and localization to avoid repeated lookups + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final publishers = ref.watch(publishersManagedProvider); final currentPublisher = useState(null); + // Initialize publisher once when data is available useEffect(() { if (publishers.value?.isNotEmpty ?? false) { currentPublisher.value = publishers.value!.first; @@ -74,7 +78,7 @@ class PostComposeScreen extends HookConsumerWidget { return null; }, [publishers]); - // Contains the XFile, ByteData, or SnCloudFile + // State management final attachments = useState>( originalPost?.attachments .map( @@ -100,12 +104,11 @@ class PostComposeScreen extends HookConsumerWidget { originalPost?.content ?? (forwardedPost != null ? '> ${forwardedPost!.content}\n\n' : null), ); - - // Add visibility state with default value from original post or 0 (public) final visibility = useState(originalPost?.visibility ?? 0); - final submitting = useState(false); + final attachmentProgress = useState>({}); + // Media handling functions Future pickPhotoMedia() async { final result = await ref .watch(imagePickerProvider) @@ -130,16 +133,30 @@ class PostComposeScreen extends HookConsumerWidget { ]; } - final attachmentProgress = useState>({}); + // Helper method to get mimetype from file type + String getMimeTypeFromFileType(UniversalFileType type) { + return switch (type) { + UniversalFileType.image => 'image/unknown', + UniversalFileType.video => 'video/unknown', + UniversalFileType.audio => 'audio/unknown', + UniversalFileType.file => 'application/octet-stream', + }; + } + // Attachment management functions Future uploadAttachment(int index) async { final attachment = attachments.value[index]; - if (attachment is SnCloudFile) return; + if (attachment.isOnCloud) return; + final baseUrl = ref.watch(serverUrlProvider); final token = await getToken(ref.watch(tokenProvider)); if (token == null) throw ArgumentError('Token is null'); + try { + // Update progress state attachmentProgress.value = {...attachmentProgress.value, index: 0}; + + // Upload file to cloud final cloudFile = await putMediaToCloud( fileData: attachment, @@ -148,32 +165,45 @@ class PostComposeScreen extends HookConsumerWidget { 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) { + getMimeTypeFromFileType(attachment.type), + onProgress: (progress, _) { attachmentProgress.value = { ...attachmentProgress.value, index: progress, }; }, ).future; + if (cloudFile == null) { throw ArgumentError('Failed to upload the file...'); } + + // Update attachments list with cloud file final clone = List.of(attachments.value); clone[index] = UniversalFile(data: cloudFile, type: attachment.type); attachments.value = clone; } catch (err) { showErrorAlert(err); } finally { - attachmentProgress.value = attachmentProgress.value..remove(index); + // Clean up progress state + attachmentProgress.value = {...attachmentProgress.value}..remove(index); } } + // Helper method to move attachment in the list + List moveAttachment( + List attachments, + int idx, + int delta, + ) { + if (idx + delta < 0 || idx + delta >= attachments.length) { + return attachments; + } + final clone = List.of(attachments); + clone.insert(idx + delta, clone.removeAt(idx)); + return clone; + } + Future deleteAttachment(int index) async { final attachment = attachments.value[index]; if (attachment.isOnCloud) { @@ -185,38 +215,52 @@ class PostComposeScreen extends HookConsumerWidget { attachments.value = clone; } + // Form submission Future performAction() async { + if (submitting.value) return; + try { submitting.value = true; + // Upload any local attachments first await Future.wait( attachments.value - .where((e) => e.isOnDevice) - .mapIndexed((idx, e) => uploadAttachment(idx)), + .asMap() + .entries + .where((entry) => entry.value.isOnDevice) + .map((entry) => uploadAttachment(entry.key)), ); + // Prepare API request final client = ref.watch(apiClientProvider); + final isNewPost = originalPost == null; + final endpoint = isNewPost ? '/posts' : '/posts/${originalPost!.id}'; + + // Create request payload + final payload = { + 'title': titleController.text, + 'description': descriptionController.text, + 'content': contentController.text, + 'visibility': visibility.value, + 'attachments': + attachments.value + .where((e) => e.isOnCloud) + .map((e) => e.data.id) + .toList(), + if (repliedPost != null) 'replied_post_id': repliedPost!.id, + if (forwardedPost != null) 'forwarded_post_id': forwardedPost!.id, + }; + + // Send request await client.request( - 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, - }, + endpoint, + data: payload, options: Options( headers: {'X-Pub': currentPublisher.value?.name}, - method: originalPost == null ? 'POST' : 'PATCH', + method: isNewPost ? 'POST' : 'PATCH', ), ); + if (context.mounted) { context.maybePop(true); } @@ -227,6 +271,7 @@ class PostComposeScreen extends HookConsumerWidget { } } + // Clipboard handling Future handlePaste() async { final clipboard = await Pasteboard.image; if (clipboard == null) return; @@ -245,60 +290,30 @@ class PostComposeScreen extends HookConsumerWidget { final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isModifierPressed = event.isMetaPressed || event.isControlPressed; + final isSubmit = event.logicalKey == LogicalKeyboardKey.enter; if (isPaste && isModifierPressed) { handlePaste(); + } else if (isSubmit && isModifierPressed && !submitting.value) { + performAction(); } } - void showVisibilityModal() { - showDialog( - context: context, - builder: - (context) => AlertDialog( - title: Text('postVisibility'.tr()), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(Symbols.public), - title: Text('postVisibilityPublic'.tr()), - onTap: () { - visibility.value = 0; - Navigator.pop(context); - }, - 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 build visibility option + Widget buildVisibilityOption( + BuildContext context, + int value, + IconData icon, + String textKey, + ) { + return ListTile( + leading: Icon(icon), + title: Text(textKey.tr()), + onTap: () { + visibility.value = value; + Navigator.pop(context); + }, + selected: visibility.value == value, ); } @@ -330,6 +345,120 @@ class PostComposeScreen extends HookConsumerWidget { } } + // Visibility handling + void showVisibilityModal() { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('postVisibility'.tr()), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildVisibilityOption( + context, + 0, + Symbols.public, + 'postVisibilityPublic', + ), + buildVisibilityOption( + context, + 1, + Symbols.group, + 'postVisibilityFriends', + ), + buildVisibilityOption( + context, + 2, + Symbols.link_off, + 'postVisibilityUnlisted', + ), + buildVisibilityOption( + context, + 3, + Symbols.lock, + 'postVisibilityPrivate', + ), + ], + ), + ), + ); + } + + // Show keyboard shortcuts dialog + void showKeyboardShortcutsDialog() { + showDialog( + context: context, + builder: + (context) => AlertDialog( + title: Text('keyboard_shortcuts'.tr()), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Ctrl/Cmd + Enter: ${'submit'.tr()}'), + Text('Ctrl/Cmd + V: ${'paste'.tr()}'), + Text('Ctrl/Cmd + I: ${'add_image'.tr()}'), + Text('Ctrl/Cmd + Shift + V: ${'add_video'.tr()}'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('close'.tr()), + ), + ], + ), + ); + } + + // Helper method to build wide attachment grid + Widget buildWideAttachmentGrid( + BoxConstraints constraints, + List attachments, + Map progress, + ) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var idx = 0; idx < attachments.length; idx++) + SizedBox( + width: constraints.maxWidth / 2 - 4, + child: AttachmentPreview( + item: attachments[idx], + progress: progress[idx], + onRequestUpload: () => uploadAttachment(idx), + onDelete: () => deleteAttachment(idx), + onMove: (delta) => moveAttachment(attachments, idx, delta), + ), + ), + ], + ); + } + + // Helper method to build narrow attachment list + Widget buildNarrowAttachmentList( + List attachments, + Map progress, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + for (var idx = 0; idx < attachments.length; idx++) + AttachmentPreview( + item: attachments[idx], + progress: progress[idx], + onRequestUpload: () => uploadAttachment(idx), + onDelete: () => deleteAttachment(idx), + onMove: (delta) => moveAttachment(attachments, idx, delta), + ), + ], + ); + } + + // Build UI return AppScaffold( appBar: AppBar( leading: const PageBackButton(), @@ -343,31 +472,7 @@ class PostComposeScreen extends HookConsumerWidget { message: 'keyboard_shortcuts'.tr(), child: IconButton( icon: const Icon(Symbols.keyboard), - onPressed: () { - 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()), - ), - ], - ), - ); - }, + onPressed: showKeyboardShortcutsDialog, ), ), IconButton( @@ -382,9 +487,9 @@ class PostComposeScreen extends HookConsumerWidget { strokeWidth: 2.5, ), ).center() - : originalPost != null - ? const Icon(Symbols.edit) - : const Icon(Symbols.upload), + : Icon( + originalPost != null ? Symbols.edit : Symbols.upload, + ), ), const Gap(8), ], @@ -392,53 +497,29 @@ class PostComposeScreen extends HookConsumerWidget { body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Reply/Forward info section if (repliedPost != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: Theme.of( - context, - ).colorScheme.surfaceVariant.withOpacity(0.5), - child: Row( - children: [ - const Icon(Symbols.reply, size: 16), - const Gap(8), - Expanded( - child: Text( - '${'reply'.tr()}: ${repliedPost!.publisher.nick}', - style: Theme.of(context).textTheme.bodySmall, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), + _buildInfoBanner( + context, + Symbols.reply, + 'reply', + repliedPost!.publisher.nick, ), 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, - ), - ), - ], - ), + _buildInfoBanner( + context, + Symbols.forward, + 'forward', + forwardedPost!.publisher.nick, ), + + // Main content area Expanded( child: Row( spacing: 12, crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Publisher profile picture GestureDetector( child: ProfilePictureWidget( fileId: currentPublisher.value?.picture?.id, @@ -458,28 +539,29 @@ class PostComposeScreen extends HookConsumerWidget { }); }, ).padding(top: 16), + + // Post content form Expanded( child: SingleChildScrollView( - padding: EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Visibility selector Row( children: [ OutlinedButton( - onPressed: () { - showVisibilityModal(); - }, + onPressed: showVisibilityModal, style: OutlinedButton.styleFrom( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), side: BorderSide( - color: Theme.of( - context, - ).colorScheme.primary.withOpacity(0.5), + color: colorScheme.primary.withOpacity(0.5), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, ), - padding: EdgeInsets.symmetric(horizontal: 16), visualDensity: const VisualDensity( vertical: -2, horizontal: -4, @@ -491,16 +573,14 @@ class PostComposeScreen extends HookConsumerWidget { Icon( getVisibilityIcon(visibility.value), size: 16, - color: - Theme.of(context).colorScheme.primary, + color: colorScheme.primary, ), const SizedBox(width: 6), Text( getVisibilityText(visibility.value).tr(), style: TextStyle( fontSize: 14, - color: - Theme.of(context).colorScheme.primary, + color: colorScheme.primary, ), ), ], @@ -508,33 +588,40 @@ class PostComposeScreen extends HookConsumerWidget { ), ], ).padding(bottom: 6), + + // Title field TextField( controller: titleController, decoration: InputDecoration.collapsed( hintText: 'postTitle'.tr(), ), - style: TextStyle(fontSize: 16), + style: const TextStyle(fontSize: 16), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), + + // Description field TextField( controller: descriptionController, decoration: InputDecoration.collapsed( hintText: 'postDescription'.tr(), ), - style: TextStyle(fontSize: 16), + style: const TextStyle(fontSize: 16), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), ), + const Gap(8), + + // Content field with keyboard listener RawKeyboardListener( focusNode: FocusNode(), onKey: handleKeyPress, child: TextField( controller: contentController, - style: TextStyle(fontSize: 14), + style: const TextStyle(fontSize: 14), decoration: InputDecoration( border: InputBorder.none, hintText: 'postPlaceholder'.tr(), @@ -547,80 +634,22 @@ class PostComposeScreen extends HookConsumerWidget { ?.unfocus(), ), ), + const Gap(8), + + // Attachments preview LayoutBuilder( builder: (context, constraints) { final isWide = isWideScreen(context); return isWide - ? Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for ( - var idx = 0; - idx < attachments.value.length; - idx++ - ) - SizedBox( - width: constraints.maxWidth / 2 - 4, - child: AttachmentPreview( - item: attachments.value[idx], - progress: - attachmentProgress.value[idx], - onRequestUpload: - () => uploadAttachment(idx), - onDelete: () => deleteAttachment(idx), - onMove: (delta) { - if (idx + delta < 0 || - idx + delta >= - attachments.value.length) { - return; - } - final clone = List.of( - attachments.value, - ); - clone.insert( - idx + delta, - clone.removeAt(idx), - ); - attachments.value = clone; - }, - ), - ), - ], + ? buildWideAttachmentGrid( + constraints, + attachments.value, + attachmentProgress.value, ) - : 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; - }, - ), - ], + : buildNarrowAttachmentList( + attachments.value, + attachmentProgress.value, ); }, ), @@ -631,6 +660,8 @@ class PostComposeScreen extends HookConsumerWidget { ], ).padding(horizontal: 16), ), + + // Bottom toolbar Material( elevation: 4, child: Row( @@ -638,12 +669,12 @@ class PostComposeScreen extends HookConsumerWidget { IconButton( onPressed: pickPhotoMedia, icon: const Icon(Symbols.add_a_photo), - color: Theme.of(context).colorScheme.primary, + color: colorScheme.primary, ), IconButton( onPressed: pickVideoMedia, icon: const Icon(Symbols.videocam), - color: Theme.of(context).colorScheme.primary, + color: colorScheme.primary, ), ], ).padding( @@ -656,4 +687,31 @@ class PostComposeScreen extends HookConsumerWidget { ), ); } + + // Helper method to build info banner for replied/forwarded posts + Widget _buildInfoBanner( + BuildContext context, + IconData icon, + String labelKey, + String publisherNick, + ) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5), + child: Row( + children: [ + Icon(icon, size: 16), + const Gap(8), + Expanded( + child: Text( + '${'labelKey'.tr()}: $publisherNick', + style: Theme.of(context).textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } } diff --git a/lib/widgets/post/post_list.dart b/lib/widgets/post/post_list.dart index 593132f..11282eb 100644 --- a/lib/widgets/post/post_list.dart +++ b/lib/widgets/post/post_list.dart @@ -51,9 +51,9 @@ class PostListNotifier extends _$PostListNotifier enum PostItemType { /// Regular post item with user information regular, - + /// Creator view with analytics and metadata - creator + creator, } class SliverPostList extends HookConsumerWidget { @@ -64,9 +64,9 @@ class SliverPostList extends HookConsumerWidget { final bool isOpenable; final Function? onRefresh; final Function(SnPost)? onUpdate; - + const SliverPostList({ - super.key, + super.key, this.pubName, this.itemType = PostItemType.regular, this.backgroundColor, @@ -89,20 +89,17 @@ class SliverPostList extends HookConsumerWidget { if (index == widgetCount - 1) { return endItemView; } - + final post = data.items[index]; - + return Column( - children: [ - _buildPostItem(post), - const Divider(height: 1), - ], + children: [_buildPostItem(post), const Divider(height: 1)], ); }, ), ); } - + Widget _buildPostItem(SnPost post) { switch (itemType) { case PostItemType.creator: @@ -115,7 +112,6 @@ class SliverPostList extends HookConsumerWidget { onUpdate: onUpdate, ); case PostItemType.regular: - default: return PostItem(item: post); } }