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;
+ }
+}