From 0463ecab23c87f7e23319fccf5d8d8b2f9968631 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Mon, 21 Sep 2026 23:39:58 -0400 Subject: [PATCH] feat: save reviewed photos and videos to the gallery --- android/app/src/main/AndroidManifest.xml | 4 + .../xcshareddata/swiftpm/Package.resolved | 20 +- .../xcshareddata/xcodecloud/manifest.json | 9 + .../xcshareddata/swiftpm/Package.resolved | 20 +- ios/Runner/Info.plist | 2 + .../templates/image_review_page_template.dart | 6 + .../templates/post_review_page_template.dart | 23 +- .../templates/video_review_page_template.dart | 6 + lib/src/core/di/service_locator.dart | 5 + lib/src/core/l10n/app_localizations.dart | 24 ++ lib/src/core/l10n/app_localizations_en.dart | 14 ++ lib/src/core/l10n/intl_en.arb | 20 ++ lib/src/core/media/media_gallery_service.dart | 9 + .../photo_manager_media_gallery_service.dart | 47 ++++ .../posting/ui/pages/image_review_page.dart | 115 +++++----- .../posting/ui/pages/video_review_page.dart | 75 ++++--- .../ui/widgets/review_media_save_action.dart | 75 +++++++ .../image_review_page_template_test.dart | 21 ++ .../video_review_page_template_test.dart | 13 ++ .../review_media_save_action_test.dart | 212 ++++++++++++++++++ 20 files changed, 611 insertions(+), 109 deletions(-) create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json create mode 100644 lib/src/core/media/media_gallery_service.dart create mode 100644 lib/src/core/media/photo_manager_media_gallery_service.dart create mode 100644 lib/src/features/posting/ui/widgets/review_media_save_action.dart create mode 100644 test/src/features/posting/ui/widgets/review_media_save_action_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 52ad577e..b117e7e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3383bc66..141f5935 100644 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", - "version" : "11.3.1" + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" + "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", + "version" : "8.1.3" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", - "version" : "5.3.0" + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "8551035fe72d8d6605cb077dd853867dc04283ec", - "version" : "3.71.4" + "revision" : "897cfb4d88d914fd83565081b555975ef2ab245d", + "version" : "3.74.1" } }, { diff --git a/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json b/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json new file mode 100644 index 00000000..7d7f3afc --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcodecloud/manifest.json @@ -0,0 +1,9 @@ +{ + "id" : "b4d29ee8-faa2-4af3-b287-d46a7072c5a4", + "targets" : [ + { + "id" : "AB369DF6-55E5-4823-AB89-137E86AA010C", + "name" : "spark" + } + ] +} \ No newline at end of file diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3383bc66..141f5935 100644 --- a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/app-check.git", "state" : { - "revision" : "3e33dd27dd4c69bd81c7c81fe61d8ccf58846902", - "version" : "11.3.1" + "revision" : "97f7d74dd0e8f3d0fe5fc75cc8957aa73bdc948a", + "version" : "11.3.2" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleDataTransport.git", "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" + "revision" : "ba3358d3c3dbae8ef230b58a46b97ad65e84e974", + "version" : "10.1.1" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/GoogleUtilities.git", "state" : { - "revision" : "9f183ae842be978784f2963a343682e0c46d8fb3", - "version" : "8.1.2" + "revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3", + "version" : "8.1.3" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/google/gtm-session-fetcher.git", "state" : { - "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", - "version" : "5.3.0" + "revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc", + "version" : "5.3.1" } }, { @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "8551035fe72d8d6605cb077dd853867dc04283ec", - "version" : "3.71.4" + "revision" : "897cfb4d88d914fd83565081b555975ef2ab245d", + "version" : "3.74.1" } }, { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 88d99f1d..ac396e98 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -56,6 +56,8 @@ This app needs microphone access to record videos with audio for content sharing NSPhotoLibraryUsageDescription This app needs access to your photo library to select images and videos for sharing + NSPhotoLibraryAddUsageDescription + Allow Spark to save photos and videos you create to your photo library UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/lib/src/core/design_system/templates/image_review_page_template.dart b/lib/src/core/design_system/templates/image_review_page_template.dart index 0179258d..b7a6119b 100644 --- a/lib/src/core/design_system/templates/image_review_page_template.dart +++ b/lib/src/core/design_system/templates/image_review_page_template.dart @@ -35,6 +35,8 @@ class ImageReviewPageTemplate extends StatelessWidget { this.showCrossPostWarning = false, this.backgroundColor, this.isOverLimit = false, + this.onSave, + this.isSaving = false, }); final String title; @@ -62,6 +64,8 @@ class ImageReviewPageTemplate extends StatelessWidget { final bool isPosting; final Color? backgroundColor; final bool isOverLimit; + final VoidCallback? onSave; + final bool isSaving; @override Widget build(BuildContext context) { @@ -69,6 +73,8 @@ class ImageReviewPageTemplate extends StatelessWidget { title: title, onBack: onBack, backgroundColor: backgroundColor, + onSave: onSave, + isSaving: isSaving, media: _PhotoStrip( imagePaths: imagePaths, onTapEditImage: onTapEditImage, diff --git a/lib/src/core/design_system/templates/post_review_page_template.dart b/lib/src/core/design_system/templates/post_review_page_template.dart index f3c6d00d..fd0d18bf 100644 --- a/lib/src/core/design_system/templates/post_review_page_template.dart +++ b/lib/src/core/design_system/templates/post_review_page_template.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_leading_button.dart'; +import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/components/atoms/toggles/app_toggle.dart'; import 'package:spark/src/core/design_system/components/molecules/input_field.dart'; import 'package:spark/src/core/design_system/tokens/typography.dart'; @@ -19,6 +20,8 @@ class PostReviewPageTemplate extends StatelessWidget { required this.onPost, required this.isPosting, this.backgroundColor, + this.onSave, + this.isSaving = false, this.status, super.key, }); @@ -32,6 +35,8 @@ class PostReviewPageTemplate extends StatelessWidget { final VoidCallback? onPost; final bool isPosting; final Color? backgroundColor; + final VoidCallback? onSave; + final bool isSaving; final Widget? status; @override @@ -49,6 +54,20 @@ class PostReviewPageTemplate extends StatelessWidget { ), title: Text(title), centerTitle: false, + actions: [ + if (onSave != null) + IconButton( + key: const ValueKey('save-media-button'), + tooltip: AppLocalizations.of(context).buttonSaveToPhotos, + onPressed: isPosting || isSaving ? null : onSave, + icon: isSaving + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const AppIcon(AppIconData.download, size: 24), + ), + ], ), body: SafeArea( top: false, @@ -65,7 +84,7 @@ class PostReviewPageTemplate extends StatelessWidget { ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.symmetric(vertical: 24), child: AbsorbPointer( - absorbing: isPosting, + absorbing: isPosting || isSaving, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -105,7 +124,7 @@ class PostReviewPageTemplate extends StatelessWidget { ], AppButton( label: postLabel, - onPressed: isPosting ? null : onPost, + onPressed: isPosting || isSaving ? null : onPost, fullWidth: true, leading: isPosting ? const SizedBox.square( diff --git a/lib/src/core/design_system/templates/video_review_page_template.dart b/lib/src/core/design_system/templates/video_review_page_template.dart index 81c834ad..f4bef811 100644 --- a/lib/src/core/design_system/templates/video_review_page_template.dart +++ b/lib/src/core/design_system/templates/video_review_page_template.dart @@ -26,6 +26,8 @@ class VideoReviewPageTemplate extends StatelessWidget { this.uploadIndeterminate = false, this.hasUploadError = false, this.onUploadRetry, + this.onSave, + this.isSaving = false, super.key, }); @@ -50,6 +52,8 @@ class VideoReviewPageTemplate extends StatelessWidget { final bool uploadIndeterminate; final bool hasUploadError; final VoidCallback? onUploadRetry; + final VoidCallback? onSave; + final bool isSaving; @override Widget build(BuildContext context) { @@ -61,6 +65,8 @@ class VideoReviewPageTemplate extends StatelessWidget { title: title, onBack: onBack, backgroundColor: backgroundColor, + onSave: onSave, + isSaving: isSaving, media: Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: Center( diff --git a/lib/src/core/di/service_locator.dart b/lib/src/core/di/service_locator.dart index 62908118..de365659 100644 --- a/lib/src/core/di/service_locator.dart +++ b/lib/src/core/di/service_locator.dart @@ -2,6 +2,8 @@ import 'package:get_it/get_it.dart'; import 'package:spark/src/core/auth/data/repositories/auth_repository_impl.dart'; import 'package:spark/src/core/auth/data/repositories/onboarding_repository.dart'; import 'package:spark/src/core/auth/data/repositories/onboarding_repository_impl.dart'; +import 'package:spark/src/core/media/media_gallery_service.dart'; +import 'package:spark/src/core/media/photo_manager_media_gallery_service.dart'; import 'package:spark/src/core/media_processing/video/pro_video_processing_service.dart'; import 'package:spark/src/core/media_processing/video/video_processing_service.dart'; import 'package:spark/src/core/network/atproto/atproto.dart'; @@ -104,6 +106,9 @@ Future initServiceLocator({ ..registerSingleton( const ProVideoProcessingService(), ) + ..registerSingleton( + const PhotoManagerMediaGalleryService(), + ) ..registerSingleton( NotificationRepositoryImpl(sl()), ); diff --git a/lib/src/core/l10n/app_localizations.dart b/lib/src/core/l10n/app_localizations.dart index d5660a90..8454e895 100644 --- a/lib/src/core/l10n/app_localizations.dart +++ b/lib/src/core/l10n/app_localizations.dart @@ -160,6 +160,30 @@ abstract class AppLocalizations { /// **'Save'** String get buttonSave; + /// Accessible label for saving reviewed media to the device photo library + /// + /// In en, this message translates to: + /// **'Save to Photos'** + String get buttonSaveToPhotos; + + /// Confirmation shown after reviewed media is saved to the device photo library + /// + /// In en, this message translates to: + /// **'Saved to Photos'** + String get messageSavedToPhotos; + + /// Error shown when permission to save reviewed media is denied + /// + /// In en, this message translates to: + /// **'Allow Spark to access Photos in your device settings, then try again.'** + String get errorPhotoLibrarySavePermission; + + /// Error shown when reviewed media cannot be saved to the device photo library + /// + /// In en, this message translates to: + /// **'Could not save to Photos. Please try again.'** + String get errorUnableToSaveToPhotos; + /// Delete option in options panel /// /// In en, this message translates to: diff --git a/lib/src/core/l10n/app_localizations_en.dart b/lib/src/core/l10n/app_localizations_en.dart index 101d515e..64072c54 100644 --- a/lib/src/core/l10n/app_localizations_en.dart +++ b/lib/src/core/l10n/app_localizations_en.dart @@ -47,6 +47,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonSave => 'Save'; + @override + String get buttonSaveToPhotos => 'Save to Photos'; + + @override + String get messageSavedToPhotos => 'Saved to Photos'; + + @override + String get errorPhotoLibrarySavePermission => + 'Allow Spark to access Photos in your device settings, then try again.'; + + @override + String get errorUnableToSaveToPhotos => + 'Could not save to Photos. Please try again.'; + @override String get buttonDelete => 'Delete'; diff --git a/lib/src/core/l10n/intl_en.arb b/lib/src/core/l10n/intl_en.arb index 48ae9ede..889e9010 100644 --- a/lib/src/core/l10n/intl_en.arb +++ b/lib/src/core/l10n/intl_en.arb @@ -34,6 +34,26 @@ "description": "Save button text" }, + "buttonSaveToPhotos": "Save to Photos", + "@buttonSaveToPhotos": { + "description": "Accessible label for saving reviewed media to the device photo library" + }, + + "messageSavedToPhotos": "Saved to Photos", + "@messageSavedToPhotos": { + "description": "Confirmation shown after reviewed media is saved to the device photo library" + }, + + "errorPhotoLibrarySavePermission": "Allow Spark to access Photos in your device settings, then try again.", + "@errorPhotoLibrarySavePermission": { + "description": "Error shown when permission to save reviewed media is denied" + }, + + "errorUnableToSaveToPhotos": "Could not save to Photos. Please try again.", + "@errorUnableToSaveToPhotos": { + "description": "Error shown when reviewed media cannot be saved to the device photo library" + }, + "buttonDelete": "Delete", "@buttonDelete": { "description": "Delete option in options panel" diff --git a/lib/src/core/media/media_gallery_service.dart b/lib/src/core/media/media_gallery_service.dart new file mode 100644 index 00000000..c26ae1c6 --- /dev/null +++ b/lib/src/core/media/media_gallery_service.dart @@ -0,0 +1,9 @@ +abstract interface class MediaGalleryService { + Future saveImages(List imagePaths); + + Future saveVideo(String videoPath); +} + +final class MediaGalleryPermissionDeniedException implements Exception { + const MediaGalleryPermissionDeniedException(); +} diff --git a/lib/src/core/media/photo_manager_media_gallery_service.dart b/lib/src/core/media/photo_manager_media_gallery_service.dart new file mode 100644 index 00000000..787af057 --- /dev/null +++ b/lib/src/core/media/photo_manager_media_gallery_service.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:photo_manager/photo_manager.dart'; +import 'package:spark/src/core/media/media_gallery_service.dart'; + +final class PhotoManagerMediaGalleryService implements MediaGalleryService { + const PhotoManagerMediaGalleryService(); + + @override + Future saveImages(List imagePaths) async { + await _requestSaveAccess(); + for (final imagePath in imagePaths) { + await PhotoManager.editor.saveImageWithPath(imagePath); + } + } + + @override + Future saveVideo(String videoPath) async { + await _requestSaveAccess(); + await PhotoManager.editor.saveVideo(File(videoPath), title: null); + } + + Future _requestSaveAccess() async { + if (Platform.isAndroid) { + final sdkVersion = int.tryParse(await PhotoManager.systemVersion()); + if (sdkVersion == null || sdkVersion >= 29) return; + + final permission = await PhotoManager.requestPermissionExtend(); + if (!permission.hasAccess) { + throw const MediaGalleryPermissionDeniedException(); + } + return; + } + + if (!Platform.isIOS) return; + + final permission = await PhotoManager.requestPermissionExtend( + requestOption: const PermissionRequestOption( + // The editor reads back the saved asset to return an AssetEntity. + iosAccessLevel: IosAccessLevel.readWrite, + ), + ); + if (!permission.hasAccess) { + throw const MediaGalleryPermissionDeniedException(); + } + } +} diff --git a/lib/src/features/posting/ui/pages/image_review_page.dart b/lib/src/features/posting/ui/pages/image_review_page.dart index 4c3a9087..270dce3c 100644 --- a/lib/src/features/posting/ui/pages/image_review_page.dart +++ b/lib/src/features/posting/ui/pages/image_review_page.dart @@ -6,9 +6,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:image_picker/image_picker.dart'; import 'package:pro_image_editor/pro_image_editor.dart'; -import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/design_system/templates/image_review_page_template.dart'; import 'package:spark/src/core/design_system/tokens/constants.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/atproto.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/utils/logging/log_service.dart'; @@ -17,6 +17,7 @@ import 'package:spark/src/features/media_editor/canvas/ui/pages/post_image_edito import 'package:spark/src/features/posting/models/mention_controller.dart'; import 'package:spark/src/features/posting/providers/post_story.dart'; import 'package:spark/src/features/posting/ui/widgets/image_sound_selection_sheet.dart'; +import 'package:spark/src/features/posting/ui/widgets/review_media_save_action.dart'; import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; import 'package:spark/src/features/sound/models/sound_audio_track.dart'; @@ -186,60 +187,68 @@ class _ImageReviewPageState extends ConsumerState { final textLength = _descriptionController.text.runes.length; final isOverLimit = textLength > AppConstants.postDescriptionMaxChars; - return ImageReviewPageTemplate( - title: l10n.pageTitleReviewPost, - onBack: () => context.router.maybePop(), - imagePaths: _imageFiles.map((e) => e.path).toList(), - onTapEditImage: _editImage, - onRemoveImage: (index) => setState(() => _imageFiles.removeAt(index)), - showAddMore: !widget.storyMode, - canAddMore: canPickMore, - onAddMore: _pickMoreImages, - selectedSoundTitle: _selectedSoundTrack?.title, - selectedSoundSubtitle: _selectedSoundTrack?.subtitle, - onAddSound: widget.storyMode ? null : _selectSound, - onRemoveSound: () => setState(() => _selectedSoundTrack = null), - mentionController: _descriptionController, - descriptionMaxChars: AppConstants.postDescriptionMaxChars, - showCaption: !widget.storyMode, - showCrossPost: !widget.storyMode, - crossPostValue: _crosspostToBsky, - onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), - showCrossPostWarning: showCrossPostWarning, - postLabel: l10n.buttonPost, - isPosting: _isPosting, - isOverLimit: isOverLimit, - onPost: _isPosting - ? null - : () async { - final postRef = await _uploadImagesAndPost(); - if (context.mounted && postRef != null) { - context.router.popUntilRoot(); - final did = ref.read(currentDidProvider); - if (did != null) { - ref - ..invalidate( - profileFeedProvider( - AtUri.parse('at://$did'), - false, - false, - ), - ) - ..invalidate( - profileFeedProvider( - AtUri.parse('at://$did'), - true, - false, - ), + return ReviewMediaSaveAction( + canSave: !_isPosting && _imageFiles.isNotEmpty, + save: (gallery) => gallery.saveImages( + _imageFiles.map((image) => image.path).toList(growable: false), + ), + builder: (context, onSave, isSaving) => ImageReviewPageTemplate( + title: l10n.pageTitleReviewPost, + onBack: () => context.router.maybePop(), + onSave: onSave, + isSaving: isSaving, + imagePaths: _imageFiles.map((e) => e.path).toList(), + onTapEditImage: _editImage, + onRemoveImage: (index) => setState(() => _imageFiles.removeAt(index)), + showAddMore: !widget.storyMode, + canAddMore: canPickMore, + onAddMore: _pickMoreImages, + selectedSoundTitle: _selectedSoundTrack?.title, + selectedSoundSubtitle: _selectedSoundTrack?.subtitle, + onAddSound: widget.storyMode ? null : _selectSound, + onRemoveSound: () => setState(() => _selectedSoundTrack = null), + mentionController: _descriptionController, + descriptionMaxChars: AppConstants.postDescriptionMaxChars, + showCaption: !widget.storyMode, + showCrossPost: !widget.storyMode, + crossPostValue: _crosspostToBsky, + onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), + showCrossPostWarning: showCrossPostWarning, + postLabel: l10n.buttonPost, + isPosting: _isPosting, + isOverLimit: isOverLimit, + onPost: _isPosting + ? null + : () async { + final postRef = await _uploadImagesAndPost(); + if (context.mounted && postRef != null) { + context.router.popUntilRoot(); + final did = ref.read(currentDidProvider); + if (did != null) { + ref + ..invalidate( + profileFeedProvider( + AtUri.parse('at://$did'), + false, + false, + ), + ) + ..invalidate( + profileFeedProvider( + AtUri.parse('at://$did'), + true, + false, + ), + ); + } + if (!widget.storyMode) { + context.router.push( + StandalonePostRoute(postUri: postRef.uri.toString()), ); + } } - if (!widget.storyMode) { - context.router.push( - StandalonePostRoute(postUri: postRef.uri.toString()), - ); - } - } - }, + }, + ), ); } } diff --git a/lib/src/features/posting/ui/pages/video_review_page.dart b/lib/src/features/posting/ui/pages/video_review_page.dart index 6ef43f01..9480403b 100644 --- a/lib/src/features/posting/ui/pages/video_review_page.dart +++ b/lib/src/features/posting/ui/pages/video_review_page.dart @@ -8,9 +8,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:get_it/get_it.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/design_system/templates/video_review_page_template.dart'; import 'package:spark/src/core/design_system/tokens/constants.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; import 'package:spark/src/core/network/atproto/atproto.dart'; import 'package:spark/src/core/routing/app_router.dart'; import 'package:spark/src/core/utils/error_messages.dart'; @@ -20,6 +20,7 @@ import 'package:spark/src/features/auth/providers/auth_providers.dart'; import 'package:spark/src/features/posting/models/mention_controller.dart'; import 'package:spark/src/features/posting/providers/video_upload_provider.dart'; import 'package:spark/src/features/posting/ui/controllers/video_review_playback_session.dart'; +import 'package:spark/src/features/posting/ui/widgets/review_media_save_action.dart'; import 'package:spark/src/features/profile/providers/profile_feed_provider.dart'; import 'package:video_player/video_player.dart'; @@ -133,7 +134,7 @@ class _VideoReviewPageState extends ConsumerState { stackTrace: stackTrace, ); if (!mounted || _isLeaving) return; - _showPostError('Unable to preview this video. Please try again.'); + _showMessage('Unable to preview this video. Please try again.'); } } @@ -240,7 +241,7 @@ class _VideoReviewPageState extends ConsumerState { _startVideoUpload(); return; } - _showPostError('Video is still uploading. Please wait for it to finish.'); + _showMessage('Video is still uploading. Please wait for it to finish.'); return; } @@ -268,7 +269,7 @@ class _VideoReviewPageState extends ConsumerState { setState(() { _isPosting = false; }); - _showPostError('Unable to create post. Please try again'); + _showMessage('Unable to create post. Please try again'); return; } @@ -295,12 +296,12 @@ class _VideoReviewPageState extends ConsumerState { setState(() { _isPosting = false; }); - _showPostError(ErrorMessages.getOperationErrorMessage('post', e)); + _showMessage(ErrorMessages.getOperationErrorMessage('post', e)); } return; } - void _showPostError(String message) { + void _showMessage(String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(message))); @@ -346,34 +347,40 @@ class _VideoReviewPageState extends ConsumerState { unawaited(_close()); } }, - child: VideoReviewPageTemplate( - title: l10n.pageTitleReviewPost, - onBack: () => unawaited(_close()), - aspectRatio: ar, - videoPreview: _playbackSession == null - ? const Center(child: CircularProgressIndicator()) - : VideoPlayer(_playbackSession!.videoController), - uploadProgress: _uploadProgress, - uploadStatusLabel: uploadStatusLabel, - uploadIndeterminate: _uploadPhase == _VideoUploadPhase.processing, - hasUploadError: _uploadErrorMessage != null, - onUploadRetry: _uploadErrorMessage == null - ? null - : () => _startVideoUpload(), - mentionController: _descriptionController, - descriptionMaxChars: AppConstants.postDescriptionMaxChars, - showCaption: !widget.storyMode, - showCrossPost: !widget.storyMode, - crossPostValue: _crosspostToBsky, - onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), - postLabel: l10n.buttonPost, - isPosting: _isPosting, - isOverLimit: isOverLimit, - onPost: canPost - ? () async { - await _postVideo(); - } - : null, + child: ReviewMediaSaveAction( + canSave: !_isPosting && !_isLeaving, + save: (gallery) => gallery.saveVideo(_video.path), + builder: (context, onSave, isSaving) => VideoReviewPageTemplate( + title: l10n.pageTitleReviewPost, + onBack: () => unawaited(_close()), + onSave: onSave, + isSaving: isSaving, + aspectRatio: ar, + videoPreview: _playbackSession == null + ? const Center(child: CircularProgressIndicator()) + : VideoPlayer(_playbackSession!.videoController), + uploadProgress: _uploadProgress, + uploadStatusLabel: uploadStatusLabel, + uploadIndeterminate: _uploadPhase == _VideoUploadPhase.processing, + hasUploadError: _uploadErrorMessage != null, + onUploadRetry: _uploadErrorMessage == null + ? null + : () => _startVideoUpload(), + mentionController: _descriptionController, + descriptionMaxChars: AppConstants.postDescriptionMaxChars, + showCaption: !widget.storyMode, + showCrossPost: !widget.storyMode, + crossPostValue: _crosspostToBsky, + onCrossPostChanged: (v) => setState(() => _crosspostToBsky = v), + postLabel: l10n.buttonPost, + isPosting: _isPosting, + isOverLimit: isOverLimit, + onPost: canPost + ? () async { + await _postVideo(); + } + : null, + ), ), ); } diff --git a/lib/src/features/posting/ui/widgets/review_media_save_action.dart b/lib/src/features/posting/ui/widgets/review_media_save_action.dart new file mode 100644 index 00000000..beb7d287 --- /dev/null +++ b/lib/src/features/posting/ui/widgets/review_media_save_action.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/media/media_gallery_service.dart'; +import 'package:spark/src/core/utils/logging/log_service.dart'; +import 'package:spark/src/core/utils/logging/logger.dart'; + +class ReviewMediaSaveAction extends StatefulWidget { + const ReviewMediaSaveAction({ + required this.save, + required this.canSave, + required this.builder, + super.key, + }); + + final Future Function(MediaGalleryService gallery) save; + final bool canSave; + final Widget Function( + BuildContext context, + VoidCallback onSave, + bool isSaving, + ) + builder; + + @override + State createState() => _ReviewMediaSaveActionState(); +} + +class _ReviewMediaSaveActionState extends State { + late final SparkLogger _logger = GetIt.I().getLogger( + 'ReviewMediaSaveAction', + ); + bool _isSaving = false; + + Future _save() async { + if (_isSaving || !widget.canSave) return; + setState(() => _isSaving = true); + + try { + await widget.save(GetIt.I()); + if (!mounted) return; + _showMessage(AppLocalizations.of(context).messageSavedToPhotos); + } on MediaGalleryPermissionDeniedException catch (error, stackTrace) { + _logger.w( + 'Photo library permission denied while saving reviewed media', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) return; + _showMessage( + AppLocalizations.of(context).errorPhotoLibrarySavePermission, + ); + } catch (error, stackTrace) { + _logger.e( + 'Failed to save reviewed media to the photo library', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) return; + _showMessage(AppLocalizations.of(context).errorUnableToSaveToPhotos); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) => + widget.builder(context, _save, _isSaving); +} diff --git a/test/src/core/design_system/templates/image_review_page_template_test.dart b/test/src/core/design_system/templates/image_review_page_template_test.dart index b03ff105..6320a5e2 100644 --- a/test/src/core/design_system/templates/image_review_page_template_test.dart +++ b/test/src/core/design_system/templates/image_review_page_template_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/components/atoms/icons.dart'; import 'package:spark/src/core/design_system/templates/image_review_page_template.dart'; import 'package:spark/src/core/l10n/app_localizations.dart'; @@ -134,6 +135,24 @@ void main() { expect(adds, 0); }); + testWidgets('save icon invokes the review save action', (tester) async { + var saves = 0; + await pumpReview( + tester, + _template(imagePaths: [imagePath], onSave: () => saves++), + ); + + final downloadIcon = find.descendant( + of: find.byKey(const ValueKey('save-media-button')), + matching: find.byType(AppIcon), + ); + expect(tester.widget(downloadIcon).icon, AppIconData.download); + expect(tester.getSize(downloadIcon), const Size.square(24)); + await tester.tap(find.byKey(const ValueKey('save-media-button'))); + + expect(saves, 1); + }); + testWidgets('posting locks media, caption, sound and cross-post controls', ( tester, ) async { @@ -230,6 +249,7 @@ ImageReviewPageTemplate _template({ VoidCallback? onAddSound, VoidCallback? onRemoveSound, ValueChanged? onCrossPostChanged, + VoidCallback? onSave, }) => ImageReviewPageTemplate( title: 'Review', onBack: () {}, @@ -250,6 +270,7 @@ ImageReviewPageTemplate _template({ selectedSoundSubtitle: selectedSoundSubtitle, onAddSound: onAddSound, onRemoveSound: onRemoveSound, + onSave: onSave, ); class _TestApp extends StatelessWidget { diff --git a/test/src/core/design_system/templates/video_review_page_template_test.dart b/test/src/core/design_system/templates/video_review_page_template_test.dart index 48270a31..efa4b6ed 100644 --- a/test/src/core/design_system/templates/video_review_page_template_test.dart +++ b/test/src/core/design_system/templates/video_review_page_template_test.dart @@ -59,6 +59,17 @@ void main() { expect(retries, 1); }); + testWidgets('save icon invokes the review save action', (tester) async { + var saves = 0; + await tester.pumpWidget( + _TestApp(child: _template(onSave: () => saves++)), + ); + + await tester.tap(find.byKey(const ValueKey('save-media-button'))); + + expect(saves, 1); + }); + testWidgets( 'processing has indeterminate progress without a fake percentage', (tester) async { @@ -124,6 +135,7 @@ VideoReviewPageTemplate _template({ bool uploadIndeterminate = false, bool hasUploadError = false, VoidCallback? onUploadRetry, + VoidCallback? onSave, }) => VideoReviewPageTemplate( title: 'Review', onBack: () {}, @@ -140,6 +152,7 @@ VideoReviewPageTemplate _template({ uploadIndeterminate: uploadIndeterminate, hasUploadError: hasUploadError, onUploadRetry: onUploadRetry, + onSave: onSave, ); void _noop() {} diff --git a/test/src/features/posting/ui/widgets/review_media_save_action_test.dart b/test/src/features/posting/ui/widgets/review_media_save_action_test.dart new file mode 100644 index 00000000..67aaa081 --- /dev/null +++ b/test/src/features/posting/ui/widgets/review_media_save_action_test.dart @@ -0,0 +1,212 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:spark/src/core/design_system/components/atoms/buttons/app_button.dart'; +import 'package:spark/src/core/design_system/templates/post_review_page_template.dart'; +import 'package:spark/src/core/l10n/app_localizations.dart'; +import 'package:spark/src/core/media/media_gallery_service.dart'; +import 'package:spark/src/core/utils/logging/log_service.dart'; +import 'package:spark/src/features/posting/ui/widgets/review_media_save_action.dart'; + +void main() { + late _Gallery gallery; + + setUp(() { + gallery = _Gallery(); + GetIt.I + ..registerSingleton(gallery) + ..registerSingleton(LogService()); + }); + + tearDown(() => GetIt.I.reset()); + + testWidgets('prevents duplicate saves and locks controls until completion', ( + tester, + ) async { + var edits = 0; + var posts = 0; + await tester.pumpWidget( + _app( + save: (gallery) => gallery.saveImages(['first.jpg', 'second.jpg']), + onEdit: () => edits++, + onPost: () => posts++, + ), + ); + + final saveButton = find.byKey(const ValueKey('save-media-button')); + await tester.tap(saveButton); + // A second tap before rebuilding must not start another write. + await tester.tap(saveButton); + await tester.pump(); + + expect(gallery.images, [ + ['first.jpg', 'second.jpg'], + ]); + expect(tester.widget(saveButton).onPressed, isNull); + expect(tester.widget(find.byType(AppButton)).onPressed, isNull); + await tester.tap(find.text('Edit'), warnIfMissed: false); + expect(edits, 0); + expect(posts, 0); + + gallery.pending.complete(); + await tester.pumpAndSettle(); + + expect(find.text('Saved to Photos'), findsOneWidget); + expect(tester.widget(saveButton).onPressed, isNotNull); + await tester.tap(find.text('Edit')); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + await tester.tap(find.byType(AppButton)); + expect(edits, 1); + expect(posts, 1); + }); + + for (final (name, error, message) in [ + ( + 'permission denial', + const MediaGalleryPermissionDeniedException(), + 'Allow Spark to access Photos in your device settings, then try again.', + ), + ( + 'save failure', + StateError('Storage unavailable'), + 'Could not save to Photos. Please try again.', + ), + ]) { + testWidgets('$name shows feedback and permits retry', (tester) async { + await tester.pumpWidget( + _app(save: (gallery) => gallery.saveVideo('review.mp4')), + ); + final saveButton = find.byKey(const ValueKey('save-media-button')); + await tester.tap(saveButton); + await tester.pump(); + gallery.pending.completeError(error); + await tester.pumpAndSettle(); + + expect(find.text(message), findsOneWidget); + expect(find.text('Saved to Photos'), findsNothing); + expect( + tester.widget(find.byType(AppButton)).onPressed, + isNotNull, + ); + + gallery.pending = Completer(); + await tester.tap(saveButton); + expect(gallery.videos, ['review.mp4', 'review.mp4']); + gallery.pending.complete(); + await tester.pumpAndSettle(); + expect(find.text('Saved to Photos'), findsOneWidget); + expect(find.text(message), findsNothing); + }); + } + + testWidgets('page eligibility guard blocks saving until enabled', ( + tester, + ) async { + await tester.pumpWidget( + _app(canSave: false, save: (gallery) => gallery.saveVideo('review.mp4')), + ); + final saveButton = find.byKey(const ValueKey('save-media-button')); + await tester.tap(saveButton); + await tester.pump(); + expect(gallery.videos, isEmpty); + expect(find.byType(SnackBar), findsNothing); + + await tester.pumpWidget( + _app(save: (gallery) => gallery.saveVideo('review.mp4')), + ); + await tester.tap(saveButton); + expect(gallery.videos, ['review.mp4']); + gallery.pending.complete(); + await tester.pumpAndSettle(); + }); + + for (final fails in [false, true]) { + testWidgets('ignores completion after disposal (failure: $fails)', ( + tester, + ) async { + final showReview = ValueNotifier(true); + addTearDown(showReview.dispose); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ValueListenableBuilder( + valueListenable: showReview, + builder: (context, show, _) => show + ? _review(save: (gallery) => gallery.saveVideo('review.mp4')) + : const Scaffold(body: Text('Destination')), + ), + ), + ); + await tester.tap(find.byKey(const ValueKey('save-media-button'))); + await tester.pump(); + showReview.value = false; + await tester.pump(); + + if (fails) { + gallery.pending.completeError(StateError('Late failure')); + } else { + gallery.pending.complete(); + } + await tester.pumpAndSettle(); + + expect(find.text('Destination'), findsOneWidget); + expect(find.byType(SnackBar), findsNothing); + expect(tester.takeException(), isNull); + }); + } +} + +Widget _app({ + required Future Function(MediaGalleryService) save, + bool canSave = true, + VoidCallback? onEdit, + VoidCallback? onPost, +}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: _review(save: save, canSave: canSave, onEdit: onEdit, onPost: onPost), +); + +Widget _review({ + required Future Function(MediaGalleryService) save, + bool canSave = true, + VoidCallback? onEdit, + VoidCallback? onPost, +}) => ReviewMediaSaveAction( + save: save, + canSave: canSave, + builder: (context, onSave, isSaving) => PostReviewPageTemplate( + title: 'Review', + onBack: () {}, + media: TextButton(onPressed: onEdit ?? () {}, child: const Text('Edit')), + caption: const SizedBox.shrink(), + options: const [], + postLabel: 'Post', + onPost: onPost ?? () {}, + isPosting: false, + onSave: onSave, + isSaving: isSaving, + ), +); + +class _Gallery implements MediaGalleryService { + late Completer pending = Completer(); + final List> images = []; + final List videos = []; + + @override + Future saveImages(List imagePaths) { + images.add(List.of(imagePaths)); + return pending.future; + } + + @override + Future saveVideo(String videoPath) { + videos.add(videoPath); + return pending.future; + } +} -- 2.51.2