From 108605b8bd3473d1a078739454c1d513b17516a9 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su <30667958+hjiangsu@users.noreply.github.com> Date: Wed, 10 Jul 2024 09:29:47 -0700 Subject: [PATCH] Add ability to upload multiple images at a time for a post/comment (#1480) added ability to upload multiple images at a time for a post/comment body --- lib/comment/cubit/create_comment_cubit.dart | 17 +++++++++++++---- lib/comment/cubit/create_comment_state.dart | 12 ++++++------ lib/comment/view/create_comment_page.dart | 7 ++++--- lib/community/pages/create_post_page.dart | 15 ++++++++------- lib/post/cubit/create_post_cubit.dart | 16 ++++++++++++---- lib/post/cubit/create_post_state.dart | 12 ++++++------ lib/utils/media/image.dart | 9 +++++++-- 7 files changed, 56 insertions(+), 32 deletions(-) diff --git a/lib/comment/cubit/create_comment_cubit.dart b/lib/comment/cubit/create_comment_cubit.dart index bd315f940..17b2c7a93 100644 --- a/lib/comment/cubit/create_comment_cubit.dart +++ b/lib/comment/cubit/create_comment_cubit.dart @@ -17,18 +17,27 @@ class CreateCommentCubit extends Cubit { emit(state.copyWith(status: CreateCommentStatus.initial, message: null)); } - Future uploadImage(String imageFile) async { + Future uploadImages(List imageFiles) async { Account? account = await fetchActiveProfileAccount(); if (account == null) return; PictrsApi pictrs = PictrsApi(account.instance!); + List urls = []; + emit(state.copyWith(status: CreateCommentStatus.imageUploadInProgress)); try { - PictrsUpload result = await pictrs.upload(filePath: imageFile, auth: account.jwt); - String url = "https://${account.instance!}/pictrs/image/${result.files[0].file}"; + for (String imageFile in imageFiles) { + PictrsUpload result = await pictrs.upload(filePath: imageFile, auth: account.jwt); + String url = "https://${account.instance!}/pictrs/image/${result.files[0].file}"; + + urls.add(url); + + // Add a delay between each upload to avoid possible rate limiting + await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); + } - emit(state.copyWith(status: CreateCommentStatus.imageUploadSuccess, imageUrl: url)); + emit(state.copyWith(status: CreateCommentStatus.imageUploadSuccess, imageUrls: urls)); } catch (e) { emit(state.copyWith(status: CreateCommentStatus.imageUploadFailure, message: e.toString())); } diff --git a/lib/comment/cubit/create_comment_state.dart b/lib/comment/cubit/create_comment_state.dart index 80cac5ef9..54708075a 100644 --- a/lib/comment/cubit/create_comment_state.dart +++ b/lib/comment/cubit/create_comment_state.dart @@ -16,7 +16,7 @@ class CreateCommentState extends Equatable { const CreateCommentState({ this.status = CreateCommentStatus.initial, this.commentView, - this.imageUrl, + this.imageUrls, this.message, }); @@ -26,8 +26,8 @@ class CreateCommentState extends Equatable { /// The result of the created or edited comment final CommentView? commentView; - /// The url of the uploaded image - final String? imageUrl; + /// The urls of the uploaded images + final List? imageUrls; /// The info or error message to be displayed as a snackbar final String? message; @@ -35,17 +35,17 @@ class CreateCommentState extends Equatable { CreateCommentState copyWith({ required CreateCommentStatus status, CommentView? commentView, - String? imageUrl, + List? imageUrls, String? message, }) { return CreateCommentState( status: status, commentView: commentView ?? this.commentView, - imageUrl: imageUrl ?? this.imageUrl, + imageUrls: imageUrls ?? this.imageUrls, message: message ?? this.message, ); } @override - List get props => [status, commentView, imageUrl, message]; + List get props => [status, commentView, imageUrls, message]; } diff --git a/lib/comment/view/create_comment_page.dart b/lib/comment/view/create_comment_page.dart index 50917d123..62a44d9aa 100644 --- a/lib/comment/view/create_comment_page.dart +++ b/lib/comment/view/create_comment_page.dart @@ -259,7 +259,8 @@ class _CreateCommentPageState extends State { switch (state.status) { case CreateCommentStatus.imageUploadSuccess: - _bodyTextController.text = _bodyTextController.text.replaceRange(_bodyTextController.selection.end, _bodyTextController.selection.end, "![](${state.imageUrl})"); + String markdownImages = state.imageUrls?.map((url) => '![]($url)').join('\n\n') ?? ''; + _bodyTextController.text = _bodyTextController.text.replaceRange(_bodyTextController.selection.end, _bodyTextController.selection.end, markdownImages); break; case CreateCommentStatus.imageUploadFailure: showSnackbar(l10n.postUploadImageError, leadingIcon: Icons.warning_rounded, leadingIconColor: theme.colorScheme.errorContainer); @@ -471,8 +472,8 @@ class _CreateCommentPageState extends State { customImageButtonAction: () async { if (state.status == CreateCommentStatus.imageUploadInProgress) return; - String imagePath = await selectImageToUpload(); - if (context.mounted) context.read().uploadImage(imagePath); + List imagesPath = await selectImagesToUpload(allowMultiple: true); + if (context.mounted) context.read().uploadImages(imagesPath); }, getAlternativeSelection: () => replyViewSelection, ), diff --git a/lib/community/pages/create_post_page.dart b/lib/community/pages/create_post_page.dart index 07ef99f3f..2eb9c731c 100644 --- a/lib/community/pages/create_post_page.dart +++ b/lib/community/pages/create_post_page.dart @@ -174,7 +174,7 @@ class _CreatePostPageState extends State { if (widget.image != null) { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (context.mounted) context.read().uploadImage(widget.image!.path, isPostImage: true); + if (context.mounted) context.read().uploadImages([widget.image!.path], isPostImage: true); }); } @@ -331,10 +331,11 @@ class _CreatePostPageState extends State { switch (state.status) { case CreatePostStatus.imageUploadSuccess: - _bodyTextController.text = _bodyTextController.text.replaceRange(_bodyTextController.selection.end, _bodyTextController.selection.end, "![](${state.imageUrl})"); + String markdownImages = state.imageUrls?.map((url) => '![]($url)').join('\n\n') ?? ''; + _bodyTextController.text = _bodyTextController.text.replaceRange(_bodyTextController.selection.end, _bodyTextController.selection.end, markdownImages); break; case CreatePostStatus.postImageUploadSuccess: - _urlTextController.text = state.imageUrl ?? ''; + _urlTextController.text = state.imageUrls?.first ?? ''; break; case CreatePostStatus.imageUploadFailure: case CreatePostStatus.postImageUploadFailure: @@ -460,8 +461,8 @@ class _CreatePostPageState extends State { onPressed: () async { if (state.status == CreatePostStatus.postImageUploadInProgress) return; - String imagePath = await selectImageToUpload(); - if (context.mounted) context.read().uploadImage(imagePath, isPostImage: true); + List imagesPath = await selectImagesToUpload(); + if (context.mounted) context.read().uploadImages(imagesPath, isPostImage: true); }, icon: state.status == CreatePostStatus.postImageUploadInProgress ? const SizedBox( @@ -604,8 +605,8 @@ class _CreatePostPageState extends State { customImageButtonAction: () async { if (state.status == CreatePostStatus.imageUploadInProgress) return; - String imagePath = await selectImageToUpload(); - if (context.mounted) context.read().uploadImage(imagePath, isPostImage: false); + List imagesPath = await selectImagesToUpload(allowMultiple: true); + if (context.mounted) context.read().uploadImages(imagesPath, isPostImage: false); }, ), ), diff --git a/lib/post/cubit/create_post_cubit.dart b/lib/post/cubit/create_post_cubit.dart index 3f407f267..919dc7392 100644 --- a/lib/post/cubit/create_post_cubit.dart +++ b/lib/post/cubit/create_post_cubit.dart @@ -19,19 +19,27 @@ class CreatePostCubit extends Cubit { emit(state.copyWith(status: CreatePostStatus.initial, message: null)); } - Future uploadImage(String imageFile, {bool isPostImage = false}) async { + Future uploadImages(List imageFiles, {bool isPostImage = false}) async { Account? account = await fetchActiveProfileAccount(); if (account == null) return; PictrsApi pictrs = PictrsApi(account.instance!); + List urls = []; isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadInProgress)) : emit(state.copyWith(status: CreatePostStatus.imageUploadInProgress)); try { - PictrsUpload result = await pictrs.upload(filePath: imageFile, auth: account.jwt); - String url = "https://${account.instance!}/pictrs/image/${result.files[0].file}"; + for (String imageFile in imageFiles) { + PictrsUpload result = await pictrs.upload(filePath: imageFile, auth: account.jwt); + String url = "https://${account.instance!}/pictrs/image/${result.files[0].file}"; - isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadSuccess, imageUrl: url)) : emit(state.copyWith(status: CreatePostStatus.imageUploadSuccess, imageUrl: url)); + urls.add(url); + + // Add a delay between each upload to avoid possible rate limiting + await Future.wait(urls.map((url) => Future.delayed(const Duration(milliseconds: 500)))); + } + + isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadSuccess, imageUrls: urls)) : emit(state.copyWith(status: CreatePostStatus.imageUploadSuccess, imageUrls: urls)); } catch (e) { isPostImage ? emit(state.copyWith(status: CreatePostStatus.postImageUploadFailure, message: e.toString())) diff --git a/lib/post/cubit/create_post_state.dart b/lib/post/cubit/create_post_state.dart index 8efb8e6e7..bb0699ab4 100644 --- a/lib/post/cubit/create_post_state.dart +++ b/lib/post/cubit/create_post_state.dart @@ -19,7 +19,7 @@ class CreatePostState extends Equatable { const CreatePostState({ this.status = CreatePostStatus.initial, this.postViewMedia, - this.imageUrl, + this.imageUrls, this.message, }); @@ -29,8 +29,8 @@ class CreatePostState extends Equatable { /// The result of the created or edited post final PostViewMedia? postViewMedia; - /// The url of the uploaded image - final String? imageUrl; + /// The urls of the uploaded images + final List? imageUrls; /// The info or error message to be displayed as a snackbar final String? message; @@ -38,17 +38,17 @@ class CreatePostState extends Equatable { CreatePostState copyWith({ required CreatePostStatus status, PostViewMedia? postViewMedia, - String? imageUrl, + List? imageUrls, String? message, }) { return CreatePostState( status: status, postViewMedia: postViewMedia ?? this.postViewMedia, - imageUrl: imageUrl ?? this.imageUrl, + imageUrls: imageUrls ?? this.imageUrls, message: message ?? this.message, ); } @override - List get props => [status, postViewMedia, imageUrl, message]; + List get props => [status, postViewMedia, imageUrls, message]; } diff --git a/lib/utils/media/image.dart b/lib/utils/media/image.dart index 071bb2592..93d8211d7 100644 --- a/lib/utils/media/image.dart +++ b/lib/utils/media/image.dart @@ -111,11 +111,16 @@ void uploadImage(BuildContext context, ImageBloc imageBloc, {bool postImage = fa } } -Future selectImageToUpload() async { +Future> selectImagesToUpload({bool allowMultiple = false}) async { final ImagePicker picker = ImagePicker(); + if (allowMultiple) { + List? files = await picker.pickMultiImage(); + return files.map((file) => file.path).toList(); + } + XFile? file = await picker.pickImage(source: ImageSource.gallery); - return file!.path; + return [file!.path]; } void showImageViewer(BuildContext context, {String? url, Uint8List? bytes, int? postId, void Function()? navigateToPost}) {