diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae84c2c..677510e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,6 +30,7 @@ and this project adheres to Calendar Versioning (CalVer).
- Block/unblock users [PR #573](https://github.com/marmot-protocol/whitenoise/pull/573), [PR #574](https://github.com/marmot-protocol/whitenoise/pull/574)
- Video media attachment support[PR #574](https://github.com/marmot-protocol/whitenoise/pull/574)
- Leave group from chat list (but disabled with a feature flag) [PR #604](https://github.com/marmot-protocol/whitenoise/pull/604), [PR #600](https://github.com/marmot-protocol/whitenoise/pull/600), [PR #571](https://github.com/marmot-protocol/whitenoise/pull/571)
+- Save to gallery [PR #619](https://github.com/marmot-protocol/whitenoise/pull/619)
### Changed
- Update System Notice colors [PR #579](https://github.com/marmot-protocol/whitenoise/pull/579)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 26ff343..b7f688d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,8 @@
+
NSCameraUsageDescription
Camera access is required to scan QR codes
+ NSPhotoLibraryAddUsageDescription
+ White Noise needs access to save images and videos to your library.
+ NSPhotoLibraryUsageDescription
+ White Noise needs access to save images and videos to your library.
diff --git a/ios/Runner/de.lproj/InfoPlist.strings b/ios/Runner/de.lproj/InfoPlist.strings
index 93cbbcc..effe374 100644
--- a/ios/Runner/de.lproj/InfoPlist.strings
+++ b/ios/Runner/de.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "Kamerazugriff ist erforderlich, um QR-Codes zu scannen";
+"NSPhotoLibraryAddUsageDescription" = "Zugriff auf die Fotobibliothek ist erforderlich, um Medien zu speichern";
+"NSPhotoLibraryUsageDescription" = "Zugriff auf die Fotobibliothek ist erforderlich, um Medien zu speichern";
diff --git a/ios/Runner/en.lproj/InfoPlist.strings b/ios/Runner/en.lproj/InfoPlist.strings
index f3091a2..0b1872f 100644
--- a/ios/Runner/en.lproj/InfoPlist.strings
+++ b/ios/Runner/en.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "Camera access is required to scan QR codes";
+"NSPhotoLibraryAddUsageDescription" = "Photo library access is required to save media";
+"NSPhotoLibraryUsageDescription" = "Photo library access is required to save media";
diff --git a/ios/Runner/es.lproj/InfoPlist.strings b/ios/Runner/es.lproj/InfoPlist.strings
index 671f9c2..2fdd7d5 100644
--- a/ios/Runner/es.lproj/InfoPlist.strings
+++ b/ios/Runner/es.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "Se requiere acceso a la cámara para escanear códigos QR";
+"NSPhotoLibraryAddUsageDescription" = "Se requiere acceso a la biblioteca de fotos para guardar fotos y videos";
+"NSPhotoLibraryUsageDescription" = "Se requiere acceso a la biblioteca de fotos para guardar fotos y videos";
diff --git a/ios/Runner/fr.lproj/InfoPlist.strings b/ios/Runner/fr.lproj/InfoPlist.strings
index 3f90b83..c7ccb1a 100644
--- a/ios/Runner/fr.lproj/InfoPlist.strings
+++ b/ios/Runner/fr.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "L'accès à la caméra est requis pour scanner les codes QR";
+"NSPhotoLibraryAddUsageDescription" = "L'accès à la photothèque est requis pour enregistrer des médias";
+"NSPhotoLibraryUsageDescription" = "L'accès à la photothèque est requis pour enregistrer des médias";
diff --git a/ios/Runner/it.lproj/InfoPlist.strings b/ios/Runner/it.lproj/InfoPlist.strings
index 334be2d..7f3b0b1 100644
--- a/ios/Runner/it.lproj/InfoPlist.strings
+++ b/ios/Runner/it.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "L'accesso alla fotocamera è necessario per scansionare i codici QR";
+"NSPhotoLibraryAddUsageDescription" = "È richiesto l'accesso alla libreria foto per salvare i media";
+"NSPhotoLibraryUsageDescription" = "È richiesto l'accesso alla libreria foto per salvare i media";
diff --git a/ios/Runner/pt.lproj/InfoPlist.strings b/ios/Runner/pt.lproj/InfoPlist.strings
index 184bf62..c949da3 100644
--- a/ios/Runner/pt.lproj/InfoPlist.strings
+++ b/ios/Runner/pt.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "O acesso à câmera é necessário para escanear códigos QR";
+"NSPhotoLibraryAddUsageDescription" = "É necessário acesso à biblioteca de fotos para salvar mídias";
+"NSPhotoLibraryUsageDescription" = "É necessário acesso à biblioteca de fotos para salvar mídias";
diff --git a/ios/Runner/ru.lproj/InfoPlist.strings b/ios/Runner/ru.lproj/InfoPlist.strings
index e0b09df..efc2004 100644
--- a/ios/Runner/ru.lproj/InfoPlist.strings
+++ b/ios/Runner/ru.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "Для сканирования QR-кодов требуется доступ к камере";
+"NSPhotoLibraryAddUsageDescription" = "Для сохранения медиафайлов требуется доступ к медиатеке";
+"NSPhotoLibraryUsageDescription" = "Для сохранения медиафайлов требуется доступ к медиатеке";
diff --git a/ios/Runner/tr.lproj/InfoPlist.strings b/ios/Runner/tr.lproj/InfoPlist.strings
index bce2272..e567de7 100644
--- a/ios/Runner/tr.lproj/InfoPlist.strings
+++ b/ios/Runner/tr.lproj/InfoPlist.strings
@@ -1 +1,3 @@
"NSCameraUsageDescription" = "QR kodlarını taramak için kamera erişimi gereklidir";
+"NSPhotoLibraryAddUsageDescription" = "Medyayı kaydetmek için fotoğraf kitaplığına erişim gereklidir";
+"NSPhotoLibraryUsageDescription" = "Medyayı kaydetmek için fotoğraf kitaplığına erişim gereklidir";
diff --git a/lib/hooks/use_save_to_gallery.dart b/lib/hooks/use_save_to_gallery.dart
new file mode 100644
index 0000000..b720d1a
--- /dev/null
+++ b/lib/hooks/use_save_to_gallery.dart
@@ -0,0 +1,82 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:gal/gal.dart';
+
+enum SaveToGalleryStatus { idle, saving, success, error }
+
+enum SaveToGalleryError { accessDenied, notEnoughSpace, notSupportedFormat, unexpected }
+
+typedef SaveToGalleryResult = ({
+ SaveToGalleryStatus status,
+ VoidCallback? save,
+ SaveToGalleryError? error,
+ bool savedRecently,
+});
+
+SaveToGalleryResult useSaveToGallery({
+ required String localPath,
+ bool isVideo = false,
+ void Function(SaveToGalleryError)? onError,
+}) {
+ final status = useState(SaveToGalleryStatus.idle);
+ final error = useState(null);
+ final savedRecently = useState(false);
+
+ useEffect(() {
+ savedRecently.value = false;
+ error.value = null;
+ return null;
+ }, [localPath]);
+
+ useEffect(() {
+ if (!savedRecently.value) return null;
+ final timer = Timer(const Duration(seconds: 2), () {
+ savedRecently.value = false;
+ status.value = SaveToGalleryStatus.idle;
+ });
+ return timer.cancel;
+ }, [savedRecently.value]);
+
+ void save() async {
+ if (localPath.isEmpty) {
+ status.value = SaveToGalleryStatus.error;
+ error.value = SaveToGalleryError.unexpected;
+ onError?.call(SaveToGalleryError.unexpected);
+ return;
+ }
+ error.value = null;
+ try {
+ status.value = SaveToGalleryStatus.saving;
+ if (isVideo) {
+ await Gal.putVideo(localPath);
+ } else {
+ await Gal.putImage(localPath);
+ }
+ status.value = SaveToGalleryStatus.success;
+ savedRecently.value = true;
+ } on GalException catch (e) {
+ status.value = SaveToGalleryStatus.error;
+ final mapped = switch (e.type) {
+ GalExceptionType.accessDenied => SaveToGalleryError.accessDenied,
+ GalExceptionType.notEnoughSpace => SaveToGalleryError.notEnoughSpace,
+ GalExceptionType.notSupportedFormat => SaveToGalleryError.notSupportedFormat,
+ GalExceptionType.unexpected => SaveToGalleryError.unexpected,
+ };
+ error.value = mapped;
+ onError?.call(mapped);
+ } catch (_) {
+ status.value = SaveToGalleryStatus.error;
+ error.value = SaveToGalleryError.unexpected;
+ onError?.call(SaveToGalleryError.unexpected);
+ }
+ }
+
+ return (
+ status: status.value,
+ save: status.value == SaveToGalleryStatus.saving ? null : save,
+ error: error.value,
+ savedRecently: savedRecently.value,
+ );
+}
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index b053b23..df290f9 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Gruppe verlassen",
"leaveGroupWarning": "Bist du sicher, dass du diese Gruppe verlassen möchtest? Der Chat bleibt in deiner Liste, wenn du ihn nicht löschst, aber du kannst keine neuen Nachrichten senden oder empfangen, es sei denn, jemand lädt dich erneut ein.",
"failedToLeaveGroup": "Gruppe konnte nicht verlassen werden. Bitte versuche es erneut.",
- "youLeftTheGroup": "Du hast die Gruppe verlassen"
+ "youLeftTheGroup": "Du hast die Gruppe verlassen",
+ "saveToGalleryPermissionDenied": "Speicherberechtigung verweigert",
+ "saveToGalleryNotEnoughSpace": "Nicht genügend Speicherplatz",
+ "saveToGalleryNotSupportedFormat": "Medienformat nicht unterstützt",
+ "saveToGalleryError": "Medium konnte nicht gespeichert werden"
}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 13b5d99..24f8ee5 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1834,8 +1834,23 @@
"waitingForInternet": "Waiting for internet connection",
"@waitingForInternet": {
"description": "Notice shown when the device has no internet connection"
- }
-,
+ },
+ "saveToGalleryPermissionDenied": "Permission denied to save image",
+ "@saveToGalleryPermissionDenied": {
+ "description": "Error message when permission is denied to save to gallery"
+ },
+ "saveToGalleryNotEnoughSpace": "Not enough storage space",
+ "@saveToGalleryNotEnoughSpace": {
+ "description": "Error message when there's not enough space to save image"
+ },
+ "saveToGalleryNotSupportedFormat": "Image format not supported",
+ "@saveToGalleryNotSupportedFormat": {
+ "description": "Error message when image format is not supported"
+ },
+ "saveToGalleryError": "Failed to save image to gallery",
+ "@saveToGalleryError": {
+ "description": "Generic error message when saving to gallery fails"
+ },
"leave": "Leave",
"@leave": { "description": "Label for the confirm leave button" },
"leaveGroup": "Leave group",
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 8061183..a4cab97 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Salir del grupo",
"leaveGroupWarning": "¿Estás seguro de que quieres salir de este grupo? El chat permanecerá en tu lista si no lo eliminas, pero no podrás enviar ni recibir mensajes nuevos a menos que alguien te vuelva a invitar.",
"failedToLeaveGroup": "No se pudo salir del grupo. Por favor, inténtalo de nuevo.",
- "youLeftTheGroup": "Saliste del grupo"
+ "youLeftTheGroup": "Saliste del grupo",
+ "saveToGalleryPermissionDenied": "Permiso denegado para guardar imagen",
+ "saveToGalleryNotEnoughSpace": "No hay suficiente espacio de almacenamiento",
+ "saveToGalleryNotSupportedFormat": "Formato de imagen no compatible",
+ "saveToGalleryError": "Error al guardar imagen en la galería"
}
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index ded30b4..23a5cb9 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Quitter le groupe",
"leaveGroupWarning": "Êtes-vous sûr de vouloir quitter ce groupe ? La discussion restera dans votre liste si vous ne la supprimez pas, mais vous ne pourrez ni envoyer ni recevoir de nouveaux messages à moins que quelqu'un ne vous invite à nouveau.",
"failedToLeaveGroup": "Impossible de quitter le groupe. Veuillez réessayer.",
- "youLeftTheGroup": "Vous avez quitté le groupe"
+ "youLeftTheGroup": "Vous avez quitté le groupe",
+ "saveToGalleryPermissionDenied": "Permission refusée pour enregistrer l'image",
+ "saveToGalleryNotEnoughSpace": "Espace de stockage insuffisant",
+ "saveToGalleryNotSupportedFormat": "Format d'image non pris en charge",
+ "saveToGalleryError": "Échec de l'enregistrement de l'image dans la galerie"
}
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index c92faa5..9d117d5 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Lascia il gruppo",
"leaveGroupWarning": "Sei sicuro di voler lasciare questo gruppo? La chat rimarrà nel tuo elenco se non la elimini, ma non potrai inviare o ricevere nuovi messaggi a meno che qualcuno non ti inviti di nuovo.",
"failedToLeaveGroup": "Impossibile lasciare il gruppo. Riprova.",
- "youLeftTheGroup": "Hai lasciato il gruppo"
+ "youLeftTheGroup": "Hai lasciato il gruppo",
+ "saveToGalleryPermissionDenied": "Permesso negato per salvare l'immagine",
+ "saveToGalleryNotEnoughSpace": "Spazio di archiviazione insufficiente",
+ "saveToGalleryNotSupportedFormat": "Formato immagine non supportato",
+ "saveToGalleryError": "Impossibile salvare l'immagine nella galleria"
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index d72d379..6518445 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Sair do grupo",
"leaveGroupWarning": "Tem certeza de que deseja sair deste grupo? O chat permanecerá na sua lista se você não o excluir, mas você não poderá enviar ou receber novas mensagens a menos que alguém o convide novamente.",
"failedToLeaveGroup": "Falha ao sair do grupo. Por favor, tente novamente.",
- "youLeftTheGroup": "Você saiu do grupo"
+ "youLeftTheGroup": "Você saiu do grupo",
+ "saveToGalleryPermissionDenied": "Permissão negada para salvar imagem",
+ "saveToGalleryNotEnoughSpace": "Espaço de armazenamento insuficiente",
+ "saveToGalleryNotSupportedFormat": "Formato de imagem não suportado",
+ "saveToGalleryError": "Falha ao salvar imagem na galeria"
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index a41143d..96d7244 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Покинуть группу",
"leaveGroupWarning": "Вы уверены, что хотите покинуть эту группу? Чат останется в вашем списке, если вы его не удалите, но вы не сможете отправлять или получать новые сообщения, пока кто-нибудь не пригласит вас снова.",
"failedToLeaveGroup": "Не удалось покинуть группу. Пожалуйста, попробуйте еще раз.",
- "youLeftTheGroup": "Вы покинули группу"
+ "youLeftTheGroup": "Вы покинули группу",
+ "saveToGalleryPermissionDenied": "Разрешение на сохранение изображения отклонено",
+ "saveToGalleryNotEnoughSpace": "Недостаточно места в хранилище",
+ "saveToGalleryNotSupportedFormat": "Формат изображения не поддерживается",
+ "saveToGalleryError": "Не удалось сохранить изображение в галерею"
}
diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb
index 092a778..524c820 100644
--- a/lib/l10n/app_tr.arb
+++ b/lib/l10n/app_tr.arb
@@ -423,5 +423,9 @@
"leaveGroup": "Gruptan ayrıl",
"leaveGroupWarning": "Bu gruptan ayrılmak istediğinize emin misiniz? Silmezseniz sohbet listenizde kalacak, ancak biri sizi yeniden davet edene kadar yeni mesaj gönderemez veya alamazsınız.",
"failedToLeaveGroup": "Gruptan ayrılma başarısız oldu. Lütfen tekrar deneyin.",
- "youLeftTheGroup": "Gruptan ayrıldınız"
+ "youLeftTheGroup": "Gruptan ayrıldınız",
+ "saveToGalleryPermissionDenied": "Görüntüyü kaydetme izni reddedildi",
+ "saveToGalleryNotEnoughSpace": "Yeterli depolama alanı yok",
+ "saveToGalleryNotSupportedFormat": "Görüntü formatı desteklenmiyor",
+ "saveToGalleryError": "Görüntü galeriye kaydedilemedi"
}
diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart
index a67c59a..b8339e4 100644
--- a/lib/l10n/generated/app_localizations.dart
+++ b/lib/l10n/generated/app_localizations.dart
@@ -2622,6 +2622,30 @@ abstract class AppLocalizations {
/// **'Waiting for internet connection'**
String get waitingForInternet;
+ /// Error message when permission is denied to save to gallery
+ ///
+ /// In en, this message translates to:
+ /// **'Permission denied to save image'**
+ String get saveToGalleryPermissionDenied;
+
+ /// Error message when there's not enough space to save image
+ ///
+ /// In en, this message translates to:
+ /// **'Not enough storage space'**
+ String get saveToGalleryNotEnoughSpace;
+
+ /// Error message when image format is not supported
+ ///
+ /// In en, this message translates to:
+ /// **'Image format not supported'**
+ String get saveToGalleryNotSupportedFormat;
+
+ /// Generic error message when saving to gallery fails
+ ///
+ /// In en, this message translates to:
+ /// **'Failed to save image to gallery'**
+ String get saveToGalleryError;
+
/// Label for the confirm leave button
///
/// In en, this message translates to:
diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart
index 61a46f7..5656f07 100644
--- a/lib/l10n/generated/app_localizations_de.dart
+++ b/lib/l10n/generated/app_localizations_de.dart
@@ -1472,6 +1472,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get waitingForInternet => 'Warten auf Internetverbindung';
+ @override
+ String get saveToGalleryPermissionDenied => 'Speicherberechtigung verweigert';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Nicht genügend Speicherplatz';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Medienformat nicht unterstützt';
+
+ @override
+ String get saveToGalleryError => 'Medium konnte nicht gespeichert werden';
+
@override
String get leave => 'Verlassen';
diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart
index e7f98eb..97212b2 100644
--- a/lib/l10n/generated/app_localizations_en.dart
+++ b/lib/l10n/generated/app_localizations_en.dart
@@ -1433,6 +1433,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get waitingForInternet => 'Waiting for internet connection';
+ @override
+ String get saveToGalleryPermissionDenied => 'Permission denied to save image';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Not enough storage space';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Image format not supported';
+
+ @override
+ String get saveToGalleryError => 'Failed to save image to gallery';
+
@override
String get leave => 'Leave';
diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart
index 8f8d06d..c2ec40b 100644
--- a/lib/l10n/generated/app_localizations_es.dart
+++ b/lib/l10n/generated/app_localizations_es.dart
@@ -1452,6 +1452,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get waitingForInternet => 'Esperando conexión a internet';
+ @override
+ String get saveToGalleryPermissionDenied => 'Permiso denegado para guardar imagen';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'No hay suficiente espacio de almacenamiento';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Formato de imagen no compatible';
+
+ @override
+ String get saveToGalleryError => 'Error al guardar imagen en la galería';
+
@override
String get leave => 'Salir';
diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart
index 1234bf7..541ae19 100644
--- a/lib/l10n/generated/app_localizations_fr.dart
+++ b/lib/l10n/generated/app_localizations_fr.dart
@@ -1453,6 +1453,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get waitingForInternet => 'En attente de connexion internet';
+ @override
+ String get saveToGalleryPermissionDenied => 'Permission refusée pour enregistrer l\'image';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Espace de stockage insuffisant';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Format d\'image non pris en charge';
+
+ @override
+ String get saveToGalleryError => 'Échec de l\'enregistrement de l\'image dans la galerie';
+
@override
String get leave => 'Quitter';
diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart
index 03d7c00..e0a799e 100644
--- a/lib/l10n/generated/app_localizations_it.dart
+++ b/lib/l10n/generated/app_localizations_it.dart
@@ -1438,6 +1438,18 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get waitingForInternet => 'In attesa della connessione a internet';
+ @override
+ String get saveToGalleryPermissionDenied => 'Permesso negato per salvare l\'immagine';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Spazio di archiviazione insufficiente';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Formato immagine non supportato';
+
+ @override
+ String get saveToGalleryError => 'Impossibile salvare l\'immagine nella galleria';
+
@override
String get leave => 'Lascia';
diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart
index 37dd65c..0304dfa 100644
--- a/lib/l10n/generated/app_localizations_pt.dart
+++ b/lib/l10n/generated/app_localizations_pt.dart
@@ -1447,6 +1447,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get waitingForInternet => 'Aguardando conexão com a internet';
+ @override
+ String get saveToGalleryPermissionDenied => 'Permissão negada para salvar imagem';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Espaço de armazenamento insuficiente';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Formato de imagem não suportado';
+
+ @override
+ String get saveToGalleryError => 'Falha ao salvar imagem na galeria';
+
@override
String get leave => 'Sair';
diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart
index 97589c0..61103cd 100644
--- a/lib/l10n/generated/app_localizations_ru.dart
+++ b/lib/l10n/generated/app_localizations_ru.dart
@@ -1469,6 +1469,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get waitingForInternet => 'Ожидание подключения к интернету';
+ @override
+ String get saveToGalleryPermissionDenied => 'Разрешение на сохранение изображения отклонено';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Недостаточно места в хранилище';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Формат изображения не поддерживается';
+
+ @override
+ String get saveToGalleryError => 'Не удалось сохранить изображение в галерею';
+
@override
String get leave => 'Покинуть';
diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart
index 01019da..8b5264a 100644
--- a/lib/l10n/generated/app_localizations_tr.dart
+++ b/lib/l10n/generated/app_localizations_tr.dart
@@ -1435,6 +1435,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get waitingForInternet => 'İnternet bağlantısı bekleniyor';
+ @override
+ String get saveToGalleryPermissionDenied => 'Görüntüyü kaydetme izni reddedildi';
+
+ @override
+ String get saveToGalleryNotEnoughSpace => 'Yeterli depolama alanı yok';
+
+ @override
+ String get saveToGalleryNotSupportedFormat => 'Görüntü formatı desteklenmiyor';
+
+ @override
+ String get saveToGalleryError => 'Görüntü galeriye kaydedilemedi';
+
@override
String get leave => 'Ayrıl';
diff --git a/lib/utils/aspect_ratio.dart b/lib/utils/aspect_ratio.dart
new file mode 100644
index 0000000..53e263f
--- /dev/null
+++ b/lib/utils/aspect_ratio.dart
@@ -0,0 +1,9 @@
+double? getAspectRatioFromDimensions(String? dimensions) {
+ if (dimensions == null) return null;
+ final parts = dimensions.split('x');
+ if (parts.length != 2) return null;
+ final w = double.tryParse(parts[0]);
+ final h = double.tryParse(parts[1]);
+ if (w == null || h == null || w <= 0 || h <= 0) return null;
+ return w / h;
+}
diff --git a/lib/widgets/local_video_player.dart b/lib/widgets/local_video_player.dart
index 8ce0d96..c4ec563 100644
--- a/lib/widgets/local_video_player.dart
+++ b/lib/widgets/local_video_player.dart
@@ -20,6 +20,7 @@ class LocalVideoPlayer extends HookWidget {
this.fit = BoxFit.contain,
this.showControls = true,
this.autoplay = false,
+ this.overlay,
});
final String filePath;
@@ -28,6 +29,7 @@ class LocalVideoPlayer extends HookWidget {
final BoxFit fit;
final bool showControls;
final bool autoplay;
+ final Widget? overlay;
@override
Widget build(BuildContext context) {
@@ -101,45 +103,79 @@ class LocalVideoPlayer extends HookWidget {
}
}
- return GestureDetector(
- key: const Key('local_video_tap_area'),
- behavior: HitTestBehavior.opaque,
- onTap: showControls ? () => unawaited(togglePlayback()) : null,
- child: Stack(
- fit: StackFit.expand,
- children: [
- if (!isInitialized.value || hasError.value)
- WnMediaPlaceholder(
- key: hasError.value
- ? const Key('video_error_placeholder')
- : const Key('video_loading_placeholder'),
- thumbHash: thumbHash,
- blurhash: blurhash,
- width: double.infinity,
- height: double.infinity,
- ),
- if (activeController != null && isInitialized.value && !hasError.value)
- FittedBox(
- fit: fit,
- child: SizedBox(
- width: activeController.value.size.width,
- height: activeController.value.size.height,
- child: VideoPlayer(
- activeController,
- key: const Key('video_player'),
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ Positioned? overlayPositioned;
+ if (overlay != null && activeController?.value.isPlaying != true) {
+ if (isInitialized.value &&
+ activeController != null &&
+ activeController.value.size.width > 0 &&
+ activeController.value.size.height > 0 &&
+ constraints.maxWidth.isFinite &&
+ constraints.maxHeight.isFinite) {
+ final availableSize = Size(constraints.maxWidth, constraints.maxHeight);
+ final fittedSizes = applyBoxFit(fit, activeController.value.size, availableSize);
+ final destSize = fittedSizes.destination;
+ final xOffset = ((availableSize.width - destSize.width) / 2).clamp(
+ 0.0,
+ double.infinity,
+ );
+ final yOffset = ((availableSize.height - destSize.height) / 2).clamp(
+ 0.0,
+ double.infinity,
+ );
+ overlayPositioned = Positioned(
+ top: yOffset + 12.h,
+ right: xOffset + 12.w,
+ child: overlay!,
+ );
+ } else {
+ overlayPositioned = Positioned(top: 12.h, right: 12.w, child: overlay!);
+ }
+ }
+
+ return GestureDetector(
+ key: const Key('local_video_tap_area'),
+ behavior: HitTestBehavior.opaque,
+ onTap: showControls ? () => unawaited(togglePlayback()) : null,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ if (!isInitialized.value || hasError.value)
+ WnMediaPlaceholder(
+ key: hasError.value
+ ? const Key('video_error_placeholder')
+ : const Key('video_loading_placeholder'),
+ thumbHash: thumbHash,
+ blurhash: blurhash,
+ width: double.infinity,
+ height: double.infinity,
),
- ),
- ),
- if (showControls || hasError.value)
- VideoPlayIndicator(
- key: const Key('video_play_indicator'),
- visible:
- !isInitialized.value ||
- hasError.value ||
- activeController?.value.isPlaying != true,
- ),
- ],
- ),
+ if (activeController != null && isInitialized.value && !hasError.value)
+ FittedBox(
+ fit: fit,
+ child: SizedBox(
+ width: activeController.value.size.width,
+ height: activeController.value.size.height,
+ child: VideoPlayer(
+ activeController,
+ key: const Key('video_player'),
+ ),
+ ),
+ ),
+ if (showControls || hasError.value)
+ VideoPlayIndicator(
+ key: const Key('video_play_indicator'),
+ visible:
+ !isInitialized.value ||
+ hasError.value ||
+ activeController?.value.isPlaying != true,
+ ),
+ if (overlayPositioned != null) overlayPositioned,
+ ],
+ ),
+ );
+ },
);
}
}
diff --git a/lib/widgets/media_image.dart b/lib/widgets/media_image.dart
index cad6deb..f5f461a 100644
--- a/lib/widgets/media_image.dart
+++ b/lib/widgets/media_image.dart
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:whitenoise/hooks/use_media_download.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
+import 'package:whitenoise/utils/aspect_ratio.dart';
import 'package:whitenoise/widgets/wn_media_error_placeholder.dart';
import 'package:whitenoise/widgets/wn_media_placeholder.dart';
@@ -11,16 +12,6 @@ const _doubleTapScale = 2.5;
const _minScale = 1.0;
const _maxScale = 4.0;
-double? _parseAspectRatio(String? dimensions) {
- if (dimensions == null) return null;
- final parts = dimensions.split('x');
- if (parts.length != 2) return null;
- final w = double.tryParse(parts[0]);
- final h = double.tryParse(parts[1]);
- if (w == null || h == null || w <= 0 || h <= 0) return null;
- return w / h;
-}
-
Matrix4 _buildZoomMatrix(Offset focalPoint, double scale) {
return Matrix4.identity()
..translateByDouble(focalPoint.dx, focalPoint.dy, 0, 1)
@@ -107,7 +98,7 @@ class MediaImage extends HookWidget {
final thumbHash = mediaFile.fileMetadata?.thumbhash;
final blurhash = mediaFile.fileMetadata?.blurhash;
final dimensions = mediaFile.fileMetadata?.dimensions;
- final aspectRatio = _parseAspectRatio(dimensions);
+ final aspectRatio = getAspectRatioFromDimensions(dimensions);
final fadeController = useAnimationController(
duration: const Duration(milliseconds: 300),
diff --git a/lib/widgets/media_modal.dart b/lib/widgets/media_modal.dart
index f579970..32be0d3 100644
--- a/lib/widgets/media_modal.dart
+++ b/lib/widgets/media_modal.dart
@@ -2,18 +2,24 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:whitenoise/hooks/use_media_download.dart';
+import 'package:whitenoise/hooks/use_save_to_gallery.dart';
+import 'package:whitenoise/hooks/use_system_notice.dart';
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/providers/locale_provider.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/theme.dart';
+import 'package:whitenoise/utils/aspect_ratio.dart';
import 'package:whitenoise/utils/media_type.dart';
import 'package:whitenoise/widgets/chat_media_thumbnail.dart';
import 'package:whitenoise/widgets/media_image.dart';
import 'package:whitenoise/widgets/media_video.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_icon.dart';
+import 'package:whitenoise/widgets/wn_icon_button.dart';
import 'package:whitenoise/widgets/wn_overlay.dart';
import 'package:whitenoise/widgets/wn_slate.dart';
+import 'package:whitenoise/widgets/wn_system_notice.dart';
class MediaModal extends HookWidget {
final List mediaFiles;
@@ -70,6 +76,9 @@ class MediaModal extends HookWidget {
final showOverlays = !isFullscreen.value && !isZoomed.value;
+ final (:noticeMessage, :noticeType, showSuccessNotice: _, :showErrorNotice, :dismissNotice) =
+ useSystemNotice();
+
return Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
@@ -87,6 +96,14 @@ class MediaModal extends HookWidget {
child: WnSlate(
key: const Key('media_modal_slate'),
animateContent: false,
+ systemNotice: noticeMessage != null
+ ? WnSystemNotice(
+ key: ValueKey(noticeMessage),
+ title: noticeMessage,
+ type: noticeType,
+ onDismiss: dismissNotice,
+ )
+ : null,
header: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
@@ -118,6 +135,7 @@ class MediaModal extends HookWidget {
curve: Curves.easeInOut,
);
},
+ onSaveError: (message) => showErrorNotice(message),
),
),
),
@@ -129,7 +147,7 @@ class MediaModal extends HookWidget {
}
}
-class _MediaContent extends StatelessWidget {
+class _MediaContent extends HookWidget {
final List mediaFiles;
final PageController pageController;
final bool isZoomed;
@@ -139,6 +157,7 @@ class _MediaContent extends StatelessWidget {
final ValueChanged onZoomChanged;
final ValueChanged onPageChanged;
final ValueChanged onThumbnailTap;
+ final ValueChanged onSaveError;
const _MediaContent({
required this.mediaFiles,
@@ -150,13 +169,35 @@ class _MediaContent extends StatelessWidget {
required this.onZoomChanged,
required this.onPageChanged,
required this.onThumbnailTap,
+ required this.onSaveError,
});
@override
Widget build(BuildContext context) {
+ final l10n = context.l10n;
+ final currentFile = mediaFiles[currentIndex];
+ final mediaDownload = useMediaDownload(mediaFile: currentFile);
+ final localPath = mediaDownload.status == MediaDownloadStatus.success
+ ? mediaDownload.localPath
+ : null;
+ final saveToGallery = useSaveToGallery(
+ localPath: localPath ?? '',
+ isVideo: isVideoMediaFile(currentFile),
+ onError: (err) {
+ final message = switch (err) {
+ SaveToGalleryError.accessDenied => l10n.saveToGalleryPermissionDenied,
+ SaveToGalleryError.notEnoughSpace => l10n.saveToGalleryNotEnoughSpace,
+ SaveToGalleryError.notSupportedFormat => l10n.saveToGalleryNotSupportedFormat,
+ SaveToGalleryError.unexpected => l10n.saveToGalleryError,
+ };
+ onSaveError(message);
+ },
+ );
+
return GestureDetector(
key: const Key('media_content_tap_area'),
onTap: onTap,
+ onLongPress: localPath != null ? saveToGallery.save : null,
behavior: HitTestBehavior.opaque,
child: Column(
children: [
@@ -169,16 +210,60 @@ class _MediaContent extends StatelessWidget {
onPageChanged: onPageChanged,
itemBuilder: (_, index) {
final mediaFile = mediaFiles[index];
+ final aspectRatio = getAspectRatioFromDimensions(
+ mediaFile.fileMetadata?.dimensions,
+ );
+
+ final downloadButton = WnIconButton(
+ key: Key('media_modal_download_button_$index'),
+ icon: saveToGallery.savedRecently ? WnIcons.checkmark : WnIcons.download,
+ onPressed: localPath != null ? saveToGallery.save : null,
+ type: WnIconButtonType.outline,
+ );
+
if (isVideoMediaFile(mediaFile)) {
return MediaVideo(
key: Key('media_video_$index'),
mediaFile: mediaFile,
+ overlay: index == currentIndex && showOverlays ? downloadButton : null,
);
}
- return MediaImage(
- key: Key('media_image_$index'),
- mediaFile: mediaFile,
- onZoomChanged: onZoomChanged,
+
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ Positioned.fill(
+ child: MediaImage(
+ key: Key('media_image_$index'),
+ mediaFile: mediaFile,
+ onZoomChanged: onZoomChanged,
+ ),
+ ),
+ if (index == currentIndex && showOverlays)
+ if (aspectRatio != null)
+ Positioned.fill(
+ child: Center(
+ child: AspectRatio(
+ aspectRatio: aspectRatio,
+ child: Stack(
+ children: [
+ Positioned(
+ top: 12.h,
+ right: 12.w,
+ child: downloadButton,
+ ),
+ ],
+ ),
+ ),
+ ),
+ )
+ else
+ Positioned(
+ top: 12.h,
+ right: 12.w,
+ child: downloadButton,
+ ),
+ ],
);
},
),
diff --git a/lib/widgets/media_video.dart b/lib/widgets/media_video.dart
index 274f758..db42ffd 100644
--- a/lib/widgets/media_video.dart
+++ b/lib/widgets/media_video.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:whitenoise/hooks/use_media_download.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/widgets/local_video_player.dart';
@@ -10,9 +11,11 @@ class MediaVideo extends HookWidget {
const MediaVideo({
super.key,
required this.mediaFile,
+ this.overlay,
});
final MediaFile mediaFile;
+ final Widget? overlay;
@override
Widget build(BuildContext context) {
@@ -21,21 +24,25 @@ class MediaVideo extends HookWidget {
final blurhash = mediaFile.fileMetadata?.blurhash;
if (status == MediaDownloadStatus.error) {
- return WnMediaErrorPlaceholder(
- key: const Key('media_video_error'),
- onRetry: retry!,
- thumbHash: thumbHash,
- blurhash: blurhash,
+ return _withOverlay(
+ WnMediaErrorPlaceholder(
+ key: const Key('media_video_error'),
+ onRetry: retry!,
+ thumbHash: thumbHash,
+ blurhash: blurhash,
+ ),
);
}
if (status != MediaDownloadStatus.success) {
- return WnMediaPlaceholder(
- key: const Key('media_video_loading'),
- thumbHash: thumbHash,
- blurhash: blurhash,
- width: double.infinity,
- height: double.infinity,
+ return _withOverlay(
+ WnMediaPlaceholder(
+ key: const Key('media_video_loading'),
+ thumbHash: thumbHash,
+ blurhash: blurhash,
+ width: double.infinity,
+ height: double.infinity,
+ ),
);
}
@@ -44,6 +51,18 @@ class MediaVideo extends HookWidget {
filePath: localPath!,
thumbHash: thumbHash,
blurhash: blurhash,
+ overlay: overlay,
+ );
+ }
+
+ Widget _withOverlay(Widget child) {
+ if (overlay == null) return child;
+ return Stack(
+ fit: StackFit.expand,
+ children: [
+ child,
+ Positioned(top: 12.h, right: 12.w, child: overlay!),
+ ],
);
}
}
diff --git a/lib/widgets/wn_media_preview.dart b/lib/widgets/wn_media_preview.dart
index 79c4b64..6b116b2 100644
--- a/lib/widgets/wn_media_preview.dart
+++ b/lib/widgets/wn_media_preview.dart
@@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart';
-import 'package:whitenoise/theme.dart';
import 'package:whitenoise/widgets/wn_icon.dart';
+import 'package:whitenoise/widgets/wn_icon_button.dart';
import 'package:whitenoise/widgets/wn_media_thumbnail.dart';
class WnMediaPreview extends HookWidget {
@@ -23,8 +23,6 @@ class WnMediaPreview extends HookWidget {
@override
Widget build(BuildContext context) {
if (children.isEmpty) return const SizedBox.shrink();
-
- final colors = context.colors;
final pageController = usePageController(initialPage: selectedIndex);
useEffect(() {
@@ -60,10 +58,11 @@ class WnMediaPreview extends HookWidget {
Positioned(
top: 10.h,
right: 10.w,
- child: _DeleteButton(
+ child: WnIconButton(
key: const Key('media_preview_delete_button'),
- onTap: onDelete!,
- colors: colors,
+ icon: WnIcons.trashCan,
+ onPressed: onDelete!,
+ type: WnIconButtonType.outline,
),
),
],
@@ -83,40 +82,6 @@ class WnMediaPreview extends HookWidget {
}
}
-class _DeleteButton extends StatelessWidget {
- const _DeleteButton({
- super.key,
- required this.onTap,
- required this.colors,
- });
-
- final VoidCallback onTap;
- final SemanticColors colors;
-
- @override
- Widget build(BuildContext context) {
- return GestureDetector(
- onTap: onTap,
- child: Container(
- width: 44.w,
- height: 44.h,
- decoration: BoxDecoration(
- color: colors.fillSecondary,
- borderRadius: BorderRadius.circular(8.r),
- border: Border.all(color: colors.borderTertiary),
- ),
- child: Center(
- child: WnIcon(
- WnIcons.trashCan,
- color: colors.backgroundContentPrimary,
- size: 18.sp,
- ),
- ),
- ),
- );
- }
-}
-
class _ThumbnailStrip extends StatelessWidget {
const _ThumbnailStrip({
super.key,
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index 48df31d..b0feb6b 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -10,6 +10,7 @@ import emoji_picker_flutter
import file_selector_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
+import gal
import mobile_scanner
import package_info_plus
import path_provider_foundation
@@ -25,6 +26,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
+ GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist
index 4789daa..c668001 100644
--- a/macos/Runner/Info.plist
+++ b/macos/Runner/Info.plist
@@ -28,5 +28,11 @@
MainMenu
NSPrincipalClass
NSApplication
+ NSCameraUsageDescription
+ Camera access is required to scan QR codes
+ NSPhotoLibraryAddUsageDescription
+ White Noise needs access to save images and videos to your library.
+ NSPhotoLibraryUsageDescription
+ White Noise needs access to save images and videos to your library.
diff --git a/pubspec.lock b/pubspec.lock
index 321adf9..baeef8b 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -565,6 +565,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
+ gal:
+ dependency: "direct main"
+ description:
+ name: gal
+ sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.2"
gap:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index cea3e32..ec6da89 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -66,6 +66,7 @@ dependencies:
flutter_local_notifications: ^21.0.0
flutter_foreground_task: ^9.2.2
permission_handler: ^12.0.0
+ gal: ^2.3.2
unique_names_generator: ^3.1.2
flutter_blurhash: ^0.9.1
thumbhash: ^0.1.0+1
diff --git a/test/hooks/use_save_to_gallery_test.dart b/test/hooks/use_save_to_gallery_test.dart
new file mode 100644
index 0000000..66e691f
--- /dev/null
+++ b/test/hooks/use_save_to_gallery_test.dart
@@ -0,0 +1,422 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:gal/gal.dart';
+import 'package:gal/src/gal_platform_interface.dart';
+import 'package:whitenoise/hooks/use_save_to_gallery.dart';
+
+import '../test_helpers.dart';
+
+base class _SuccessGalPlatform extends GalPlatform {
+ @override
+ Future putImage(String path, {String? album}) async {}
+
+ @override
+ Future putVideo(String path, {String? album}) async {}
+}
+
+base class _ErrorGalPlatform extends GalPlatform {
+ final GalExceptionType type;
+ _ErrorGalPlatform(this.type);
+
+ @override
+ Future putImage(String path, {String? album}) async {
+ throw GalException(
+ type: type,
+ platformException: PlatformException(code: type.code),
+ stackTrace: StackTrace.current,
+ );
+ }
+
+ @override
+ Future putVideo(String path, {String? album}) async {
+ throw GalException(
+ type: type,
+ platformException: PlatformException(code: type.code),
+ stackTrace: StackTrace.current,
+ );
+ }
+}
+
+base class _GenericErrorGalPlatform extends GalPlatform {
+ @override
+ Future putImage(String path, {String? album}) async {
+ throw Exception('Generic error');
+ }
+
+ @override
+ Future putVideo(String path, {String? album}) async {
+ throw Exception('Generic error');
+ }
+}
+
+base class _SlowGalPlatform extends GalPlatform {
+ final Completer completer;
+ _SlowGalPlatform(this.completer);
+
+ @override
+ Future putImage(String path, {String? album}) => completer.future;
+
+ @override
+ Future putVideo(String path, {String? album}) => completer.future;
+}
+
+Widget _buildHookWidget(String path, void Function(SaveToGalleryResult) onResult) {
+ return MaterialApp(
+ locale: const Locale('en'),
+ home: HookBuilder(
+ builder: (context) {
+ final result = useSaveToGallery(localPath: path);
+ onResult(result);
+ return const SizedBox.shrink();
+ },
+ ),
+ );
+}
+
+void main() {
+ group('useSaveToGallery', () {
+ late GalPlatform galPlatform;
+
+ setUp(() {
+ galPlatform = GalPlatform.instance;
+ });
+
+ tearDown(() {
+ GalPlatform.instance = galPlatform;
+ });
+
+ test('has correct initial state', () {
+ expect(SaveToGalleryStatus.idle.name, 'idle');
+ expect(SaveToGalleryStatus.saving.name, 'saving');
+ expect(SaveToGalleryStatus.success.name, 'success');
+ expect(SaveToGalleryStatus.error.name, 'error');
+ });
+
+ test('SaveToGalleryResult has correct structure', () {
+ final SaveToGalleryResult result = (
+ status: SaveToGalleryStatus.idle,
+ save: () {},
+ error: null,
+ savedRecently: false,
+ );
+
+ expect(result.status, SaveToGalleryStatus.idle);
+ expect(result.save, isA());
+ expect(result.error, isNull);
+ expect(result.savedRecently, isFalse);
+ });
+
+ test('SaveToGalleryResult has correct structure with error field', () {
+ final SaveToGalleryResult result = (
+ status: SaveToGalleryStatus.error,
+ save: null,
+ error: SaveToGalleryError.unexpected,
+ savedRecently: false,
+ );
+
+ expect(result.status, SaveToGalleryStatus.error);
+ expect(result.save, isNull);
+ expect(result.error, isA());
+ expect(result.error, SaveToGalleryError.unexpected);
+ expect(result.savedRecently, isFalse);
+ });
+
+ testWidgets('save() sets error status when localPath is empty', (tester) async {
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: ''),
+ );
+
+ hook().save!();
+ await tester.pump();
+
+ expect(hook().status, SaveToGalleryStatus.error);
+ expect(hook().error, SaveToGalleryError.unexpected);
+ });
+
+ testWidgets('savedRecently is false initially', (tester) async {
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/path'),
+ );
+
+ expect(hook().savedRecently, isFalse);
+ });
+
+ testWidgets('savedRecently becomes true after successful save', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/path'),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(hook().savedRecently, isTrue);
+ });
+
+ testWidgets('savedRecently reverts to false after 2 seconds', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/path'),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(hook().savedRecently, isTrue);
+
+ await tester.pump(const Duration(seconds: 2));
+
+ expect(hook().savedRecently, isFalse);
+ });
+
+ testWidgets('status resets to idle after savedRecently timer fires', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/path'),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ await tester.pump(const Duration(seconds: 2));
+
+ expect(hook().status, SaveToGalleryStatus.idle);
+ });
+
+ testWidgets('savedRecently resets when localPath changes', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+
+ late SaveToGalleryResult result;
+ await tester.pumpWidget(_buildHookWidget('/path/a', (r) => result = r));
+ await tester.pump();
+
+ result.save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(result.savedRecently, isTrue);
+
+ await tester.pumpWidget(_buildHookWidget('/path/b', (r) => result = r));
+ await tester.pump();
+
+ expect(result.savedRecently, isFalse);
+ });
+
+ testWidgets('error resets when localPath changes', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
+
+ late SaveToGalleryResult result;
+ await tester.pumpWidget(_buildHookWidget('/path/a', (r) => result = r));
+ await tester.pump();
+
+ result.save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(result.error, SaveToGalleryError.accessDenied);
+
+ await tester.pumpWidget(_buildHookWidget('/path/b', (r) => result = r));
+ await tester.pump();
+
+ expect(result.error, isNull);
+ });
+
+ testWidgets('onError called with accessDenied', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
+
+ SaveToGalleryError? receivedError;
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(
+ localPath: '/some/path',
+ onError: (err) => receivedError = err,
+ ),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(receivedError, SaveToGalleryError.accessDenied);
+ });
+
+ testWidgets('onError called with notEnoughSpace', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.notEnoughSpace);
+
+ SaveToGalleryError? receivedError;
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(
+ localPath: '/some/path',
+ onError: (err) => receivedError = err,
+ ),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(receivedError, SaveToGalleryError.notEnoughSpace);
+ });
+
+ testWidgets('onError called with notSupportedFormat', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.notSupportedFormat);
+
+ SaveToGalleryError? receivedError;
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(
+ localPath: '/some/path',
+ onError: (err) => receivedError = err,
+ ),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(receivedError, SaveToGalleryError.notSupportedFormat);
+ });
+
+ testWidgets('onError called with unexpected GalException', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.unexpected);
+
+ SaveToGalleryError? receivedError;
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(
+ localPath: '/some/path',
+ onError: (err) => receivedError = err,
+ ),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(receivedError, SaveToGalleryError.unexpected);
+ });
+
+ testWidgets('onError called with unexpected on generic exception', (tester) async {
+ GalPlatform.instance = _GenericErrorGalPlatform();
+
+ SaveToGalleryError? receivedError;
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(
+ localPath: '/some/path',
+ onError: (err) => receivedError = err,
+ ),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(receivedError, SaveToGalleryError.unexpected);
+ });
+
+ testWidgets('onError called with unexpected when localPath is empty', (tester) async {
+ SaveToGalleryError? receivedError;
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(
+ localPath: '',
+ onError: (err) => receivedError = err,
+ ),
+ );
+
+ hook().save!();
+ await tester.pump();
+
+ expect(receivedError, SaveToGalleryError.unexpected);
+ });
+
+ testWidgets('save is null while saving', (tester) async {
+ final completer = Completer();
+ GalPlatform.instance = _SlowGalPlatform(completer);
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/path'),
+ );
+
+ hook().save!();
+ await tester.pump();
+
+ expect(hook().save, isNull);
+
+ completer.complete();
+ await tester.pump();
+ await tester.pump();
+
+ expect(hook().save, isNotNull);
+ });
+
+ testWidgets('save() calls putVideo when isVideo is true and succeeds', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/video.mp4', isVideo: true),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(hook().savedRecently, isTrue);
+ });
+
+ testWidgets('save() handles GalException when saving video', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/video.mp4', isVideo: true),
+ );
+
+ hook().save!();
+ await tester.pump();
+ await tester.pump();
+
+ expect(hook().error, SaveToGalleryError.accessDenied);
+ });
+
+ testWidgets('save is null while saving video', (tester) async {
+ final completer = Completer();
+ GalPlatform.instance = _SlowGalPlatform(completer);
+
+ final hook = await mountHook(
+ tester,
+ () => useSaveToGallery(localPath: '/some/video.mp4', isVideo: true),
+ );
+
+ hook().save!();
+ await tester.pump();
+
+ expect(hook().save, isNull);
+
+ completer.complete();
+ await tester.pump();
+ await tester.pump();
+
+ expect(hook().save, isNotNull);
+ });
+ });
+}
diff --git a/test/utils/aspect_ratio_test.dart b/test/utils/aspect_ratio_test.dart
new file mode 100644
index 0000000..735dec6
--- /dev/null
+++ b/test/utils/aspect_ratio_test.dart
@@ -0,0 +1,66 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:whitenoise/utils/aspect_ratio.dart';
+
+void main() {
+ group('getAspectRatioFromDimensions', () {
+ group('returns null', () {
+ test('with null input', () {
+ expect(getAspectRatioFromDimensions(null), isNull);
+ });
+
+ test('with empty string', () {
+ expect(getAspectRatioFromDimensions(''), isNull);
+ });
+
+ test('with missing separator', () {
+ expect(getAspectRatioFromDimensions('1920'), isNull);
+ });
+
+ test('with more than two parts', () {
+ expect(getAspectRatioFromDimensions('1920x1080x10'), isNull);
+ });
+
+ test('with non-numeric width', () {
+ expect(getAspectRatioFromDimensions('foox1080'), isNull);
+ });
+
+ test('with non-numeric height', () {
+ expect(getAspectRatioFromDimensions('1920xbar'), isNull);
+ });
+
+ test('with zero width', () {
+ expect(getAspectRatioFromDimensions('0x1080'), isNull);
+ });
+
+ test('with zero height', () {
+ expect(getAspectRatioFromDimensions('1920x0'), isNull);
+ });
+
+ test('with negative width', () {
+ expect(getAspectRatioFromDimensions('-1920x1080'), isNull);
+ });
+
+ test('with negative height', () {
+ expect(getAspectRatioFromDimensions('1920x-1080'), isNull);
+ });
+ });
+
+ group('returns aspect ratio', () {
+ test('for landscape dimensions', () {
+ expect(getAspectRatioFromDimensions('1920x1080'), 1920 / 1080);
+ });
+
+ test('for portrait dimensions', () {
+ expect(getAspectRatioFromDimensions('1080x1920'), 1080 / 1920);
+ });
+
+ test('for square dimensions', () {
+ expect(getAspectRatioFromDimensions('500x500'), 1.0);
+ });
+
+ test('for decimal dimensions', () {
+ expect(getAspectRatioFromDimensions('100.5x50.25'), 100.5 / 50.25);
+ });
+ });
+ });
+}
diff --git a/test/widgets/chat_message_media_test.dart b/test/widgets/chat_message_media_test.dart
index 3fd4a0c..5194207 100644
--- a/test/widgets/chat_message_media_test.dart
+++ b/test/widgets/chat_message_media_test.dart
@@ -235,6 +235,18 @@ void main() {
expect(find.byKey(const Key('media_image')), findsOneWidget);
});
+ testWidgets('shows video loading indicator while video is downloading', (tester) async {
+ _api.downloadCompleter = Completer();
+ await mountWidget(
+ ChatMessageMedia(
+ mediaFiles: [_mediaFile(mimeType: 'video/mp4', mediaType: 'video')],
+ ),
+ tester,
+ );
+
+ expect(find.byKey(const Key('media_video_loading_indicator')), findsOneWidget);
+ });
+
testWidgets('shows video preview with play indicator on success', (tester) async {
setUpFakeVideoPlayerPlatform();
final tempDir = Directory.systemTemp.createTempSync('chat_media_video_test');
diff --git a/test/widgets/local_video_player_test.dart b/test/widgets/local_video_player_test.dart
index 09244e1..95ffa8d 100644
--- a/test/widgets/local_video_player_test.dart
+++ b/test/widgets/local_video_player_test.dart
@@ -130,6 +130,58 @@ void main() {
expect(disposeIndex, lessThan(secondCreateIndex));
expect(fakeVideoPlatform.dataSources.last.uri, contains('clip2.mp4'));
});
+
+ testWidgets('renders overlay at fallback position before initialization', (tester) async {
+ fakeVideoPlatform.forceInitError = true;
+
+ await mountWidget(
+ LocalVideoPlayer(
+ filePath: videoFile.path,
+ overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
+ ),
+ tester,
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+ });
+
+ testWidgets('renders overlay inside video bounds after initialization', (tester) async {
+ await mountWidget(
+ LocalVideoPlayer(
+ filePath: videoFile.path,
+ overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
+ ),
+ tester,
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('video_player')), findsOneWidget);
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+ });
+
+ testWidgets('hides overlay while video is playing, restores when paused', (tester) async {
+ await mountWidget(
+ LocalVideoPlayer(
+ filePath: videoFile.path,
+ overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
+ ),
+ tester,
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+
+ await tester.tap(find.byKey(const Key('local_video_tap_area')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('test_overlay')), findsNothing);
+
+ await tester.tap(find.byKey(const Key('local_video_tap_area')));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+ });
});
group('VideoPlayIndicator', () {
diff --git a/test/widgets/media_modal_test.dart b/test/widgets/media_modal_test.dart
index 5abc559..a74ba2e 100644
--- a/test/widgets/media_modal_test.dart
+++ b/test/widgets/media_modal_test.dart
@@ -1,20 +1,74 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:gal/gal.dart';
+import 'package:gal/src/gal_platform_interface.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/widgets/media_image.dart';
import 'package:whitenoise/widgets/media_modal.dart';
import 'package:whitenoise/widgets/media_video.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
+import 'package:whitenoise/widgets/wn_icon.dart';
+import 'package:whitenoise/widgets/wn_icon_button.dart';
import 'package:whitenoise/widgets/wn_overlay.dart';
+import 'package:whitenoise/widgets/wn_system_notice.dart';
import '../mocks/mock_wn_api.dart';
import '../test_helpers.dart';
+base class _SuccessGalPlatform extends GalPlatform {
+ @override
+ Future putImage(String path, {String? album}) async {}
+}
+
+base class _ErrorGalPlatform extends GalPlatform {
+ final GalExceptionType type;
+ _ErrorGalPlatform(this.type);
+
+ @override
+ Future putImage(String path, {String? album}) async {
+ throw GalException(
+ type: type,
+ platformException: PlatformException(code: type.code),
+ stackTrace: StackTrace.current,
+ );
+ }
+}
+
+base class _GenericErrorGalPlatform extends GalPlatform {
+ @override
+ Future putImage(String path, {String? album}) async {
+ throw Exception('Generic error');
+ }
+}
+
+base class _VideoSuccessGalPlatform extends GalPlatform {
+ @override
+ Future putVideo(String path, {String? album}) async {}
+}
+
+base class _VideoErrorGalPlatform extends GalPlatform {
+ final GalExceptionType type;
+ _VideoErrorGalPlatform(this.type);
+
+ @override
+ Future putVideo(String path, {String? album}) async {
+ throw GalException(
+ type: type,
+ platformException: PlatformException(code: type.code),
+ stackTrace: StackTrace.current,
+ );
+ }
+}
+
MediaFile _mediaFile(
String id, {
String filePath = '',
String? blurhash,
+ String? dimensions,
String mimeType = 'image/jpeg',
String mediaType = 'image',
}) => MediaFile(
@@ -29,13 +83,215 @@ MediaFile _mediaFile(
blossomUrl: 'https://example.com/$id',
nostrKey: 'nostr$id',
createdAt: DateTime(2024),
- fileMetadata: blurhash != null ? FileMetadata(blurhash: blurhash) : null,
+ fileMetadata: (blurhash != null || dimensions != null)
+ ? FileMetadata(blurhash: blurhash, dimensions: dimensions)
+ : null,
);
+// Minimal valid 1x1 PNG. Required because Image.file with invalid bytes
+// causes the native image codec to hang the test pump loop.
+const _minimalPng = [
+ 0x89,
+ 0x50,
+ 0x4E,
+ 0x47,
+ 0x0D,
+ 0x0A,
+ 0x1A,
+ 0x0A,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x0D,
+ 0x49,
+ 0x48,
+ 0x44,
+ 0x52,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x01,
+ 0x08,
+ 0x02,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x90,
+ 0x77,
+ 0x53,
+ 0xDE,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x0C,
+ 0x49,
+ 0x44,
+ 0x41,
+ 0x54,
+ 0x08,
+ 0xD7,
+ 0x63,
+ 0xF8,
+ 0xCF,
+ 0xC0,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x02,
+ 0x00,
+ 0x01,
+ 0xE2,
+ 0x21,
+ 0xBC,
+ 0x33,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x49,
+ 0x45,
+ 0x4E,
+ 0x44,
+ 0xAE,
+ 0x42,
+ 0x60,
+ 0x82,
+];
+
+Future _openAndTapDownload(
+ WidgetTester tester,
+ String filePath,
+ String id,
+) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [_mediaFile(id, filePath: filePath)],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+
+ await tester.tap(find.byKey(const Key('media_modal_download_button_0')));
+ await tester.pump();
+ await tester.pump();
+ await tester.pump();
+}
+
+Future _openAndLongPress(
+ WidgetTester tester,
+ String filePath,
+ String id,
+) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [_mediaFile(id, filePath: filePath)],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+
+ await tester.longPress(find.byKey(const Key('media_content_tap_area')));
+ await tester.pump();
+ await tester.pump();
+ await tester.pump();
+}
+
+Future _openAndTapDownloadVideo(
+ WidgetTester tester,
+ String filePath,
+ String id,
+) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [
+ _mediaFile(id, filePath: filePath, mimeType: 'video/mp4', mediaType: 'video'),
+ ],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+
+ await tester.tap(find.byKey(const Key('media_modal_download_button_0')));
+ await tester.pump();
+ await tester.pump();
+ await tester.pump();
+}
+
+Future _openAndLongPressVideo(
+ WidgetTester tester,
+ String filePath,
+ String id,
+) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [
+ _mediaFile(id, filePath: filePath, mimeType: 'video/mp4', mediaType: 'video'),
+ ],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+
+ await tester.longPress(find.byKey(const Key('media_content_tap_area')));
+ await tester.pump();
+ await tester.pump();
+ await tester.pump();
+}
+
void main() {
setUpAll(() => RustLib.initMock(api: MockWnApi()));
group('MediaModal', () {
+ late GalPlatform galPlatform;
+
+ setUp(() {
+ galPlatform = GalPlatform.instance;
+ });
+
+ tearDown(() {
+ GalPlatform.instance = galPlatform;
+ });
+
testWidgets('renders overlay background', (tester) async {
await mountWidget(
Builder(
@@ -356,6 +612,62 @@ void main() {
expect(find.byKey(const Key('media_modal_sender_name')), findsNothing);
});
+ testWidgets('wraps download button in AspectRatio when image has dimensions', (tester) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [_mediaFile('1', dimensions: '1600x900')],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ final aspectRatio = tester.widget(
+ find
+ .ancestor(
+ of: find.byKey(const Key('media_modal_download_button_0')),
+ matching: find.byType(AspectRatio),
+ )
+ .first,
+ );
+ expect(aspectRatio.aspectRatio, 1600 / 900);
+ });
+
+ testWidgets('does not wrap download button in AspectRatio when image has no dimensions', (
+ tester,
+ ) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [_mediaFile('1')],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ expect(
+ find.ancestor(
+ of: find.byKey(const Key('media_modal_download_button_0')),
+ matching: find.byType(AspectRatio),
+ ),
+ findsNothing,
+ );
+ });
+
testWidgets('page view uses NeverScrollableScrollPhysics when zoomed', (tester) async {
await mountWidget(
Builder(
@@ -380,5 +692,291 @@ void main() {
final pageView = tester.widget(find.byKey(const Key('media_page_view')));
expect(pageView.physics, isA());
});
+
+ testWidgets('shows error system notice when gallery save fails with permission denied', (
+ tester,
+ ) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
+ final dir = Directory.systemTemp.createTempSync('mm_access');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_e1');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ expect(find.text('Permission denied to save image'), findsOneWidget);
+ });
+
+ testWidgets('shows error system notice when gallery save fails with not enough space', (
+ tester,
+ ) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.notEnoughSpace);
+ final dir = Directory.systemTemp.createTempSync('mm_space');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_e2');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ expect(find.text('Not enough storage space'), findsOneWidget);
+ });
+
+ testWidgets('shows error system notice when gallery save fails with unsupported format', (
+ tester,
+ ) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.notSupportedFormat);
+ final dir = Directory.systemTemp.createTempSync('mm_format');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_e3');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ expect(find.text('Image format not supported'), findsOneWidget);
+ });
+
+ testWidgets(
+ 'shows error system notice when gallery save fails with unexpected GalException',
+ (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.unexpected);
+ final dir = Directory.systemTemp.createTempSync('mm_unexpected');
+ final file = File('${dir.path}/test.png')
+ ..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_e4');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ expect(find.text('Failed to save image to gallery'), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'shows error system notice when gallery save fails with generic exception',
+ (tester) async {
+ GalPlatform.instance = _GenericErrorGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_generic');
+ final file = File('${dir.path}/test.png')
+ ..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_e5');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ expect(find.text('Failed to save image to gallery'), findsOneWidget);
+ },
+ );
+
+ testWidgets('shows checkmark on download button after saving to gallery', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_checkmark');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_c1');
+
+ final button = tester.widget(
+ find.byKey(const Key('media_modal_download_button_0')),
+ );
+ expect(button.icon, WnIcons.checkmark);
+ });
+
+ testWidgets('checkmark reverts to download icon after timeout', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_revert');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_c2');
+
+ var button = tester.widget(
+ find.byKey(const Key('media_modal_download_button_0')),
+ );
+ expect(button.icon, WnIcons.checkmark);
+
+ await tester.pump(const Duration(seconds: 2));
+
+ button = tester.widget(
+ find.byKey(const Key('media_modal_download_button_0')),
+ );
+ expect(button.icon, WnIcons.download);
+ });
+
+ testWidgets('download button re-enables after save so image can be saved multiple times', (
+ tester,
+ ) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_reenable');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'save_r1');
+
+ await tester.pump(const Duration(seconds: 2));
+
+ final button = tester.widget(
+ find.descendant(
+ of: find.byKey(const Key('media_modal_download_button_0')),
+ matching: find.byType(FilledButton),
+ ),
+ );
+ expect(button.onPressed, isNotNull);
+ });
+
+ testWidgets('long press saves to gallery successfully', (tester) async {
+ GalPlatform.instance = _SuccessGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_lp_success');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndLongPress(tester, file.path, 'lp_s1');
+
+ final button = tester.widget(
+ find.byKey(const Key('media_modal_download_button_0')),
+ );
+ expect(button.icon, WnIcons.checkmark);
+ });
+
+ testWidgets('long press shows error notice when save fails', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
+ final dir = Directory.systemTemp.createTempSync('mm_lp_error');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndLongPress(tester, file.path, 'lp_e1');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ expect(find.text('Permission denied to save image'), findsOneWidget);
+ });
+
+ testWidgets('long press is disabled when media is not downloaded', (tester) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [_mediaFile('lp_d1')],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ final gestureDetector = tester.widget(
+ find.byKey(const Key('media_content_tap_area')),
+ );
+ expect(gestureDetector.onLongPress, isNull);
+ });
+
+ testWidgets('shows download button for video media', (tester) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [
+ _mediaFile('vid_btn_1', mimeType: 'video/mp4', mediaType: 'video'),
+ ],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_modal_download_button_0')), findsOneWidget);
+ });
+
+ testWidgets('video download button saves to gallery successfully', (tester) async {
+ GalPlatform.instance = _VideoSuccessGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_vid_success');
+ final file = File('${dir.path}/test.mp4')..writeAsBytesSync([0]);
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownloadVideo(tester, file.path, 'vid_dl_s1');
+
+ final button = tester.widget(
+ find.byKey(const Key('media_modal_download_button_0')),
+ );
+ expect(button.icon, WnIcons.checkmark);
+ });
+
+ testWidgets('video long press saves to gallery successfully', (tester) async {
+ GalPlatform.instance = _VideoSuccessGalPlatform();
+ final dir = Directory.systemTemp.createTempSync('mm_vid_lp_success');
+ final file = File('${dir.path}/test.mp4')..writeAsBytesSync([0]);
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndLongPressVideo(tester, file.path, 'vid_lp_s1');
+
+ final button = tester.widget(
+ find.byKey(const Key('media_modal_download_button_0')),
+ );
+ expect(button.icon, WnIcons.checkmark);
+ });
+
+ testWidgets('video long press shows error notice when save fails', (tester) async {
+ GalPlatform.instance = _VideoErrorGalPlatform(GalExceptionType.accessDenied);
+ final dir = Directory.systemTemp.createTempSync('mm_vid_lp_err');
+ final file = File('${dir.path}/test.mp4')..writeAsBytesSync([0]);
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndLongPressVideo(tester, file.path, 'vid_lp_e1');
+
+ expect(find.byType(WnSystemNotice), findsOneWidget);
+ });
+
+ testWidgets('system notice for errors is rendered inside the slate', (tester) async {
+ GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
+ final dir = Directory.systemTemp.createTempSync('mm_notice_slate');
+ final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
+ addTearDown(() => dir.deleteSync(recursive: true));
+
+ await _openAndTapDownload(tester, file.path, 'notice_slate_1');
+
+ expect(
+ find.descendant(
+ of: find.byKey(const Key('media_modal_slate')),
+ matching: find.byType(WnSystemNotice),
+ ),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('video download button is overlaid on the video player', (tester) async {
+ await mountWidget(
+ Builder(
+ builder: (context) => ElevatedButton(
+ onPressed: () => MediaModal.show(
+ context: context,
+ mediaFiles: [
+ _mediaFile('vid_ar_1', mimeType: 'video/mp4', mediaType: 'video'),
+ ],
+ ),
+ child: const Text('Open'),
+ ),
+ ),
+ tester,
+ );
+
+ await tester.tap(find.text('Open'));
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_modal_download_button_0')), findsOneWidget);
+ expect(
+ find.descendant(
+ of: find.byType(MediaVideo),
+ matching: find.byKey(const Key('media_modal_download_button_0')),
+ ),
+ findsOneWidget,
+ );
+ });
});
}
diff --git a/test/widgets/media_video_test.dart b/test/widgets/media_video_test.dart
index dda54cf..3f0d82c 100644
--- a/test/widgets/media_video_test.dart
+++ b/test/widgets/media_video_test.dart
@@ -101,5 +101,56 @@ void main() {
expect(find.byKey(const Key('media_video_player')), findsOneWidget);
expect(find.byKey(const Key('video_player')), findsOneWidget);
});
+
+ testWidgets('renders overlay on loading placeholder', (tester) async {
+ _api.downloadCompleter = Completer();
+
+ await mountWidget(
+ MediaVideo(
+ mediaFile: _mediaFile(),
+ overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
+ ),
+ tester,
+ );
+
+ expect(find.byKey(const Key('media_video_loading')), findsOneWidget);
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+ });
+
+ testWidgets('renders overlay on error placeholder', (tester) async {
+ _api.shouldFail = true;
+
+ await mountWidget(
+ MediaVideo(
+ mediaFile: _mediaFile(),
+ overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
+ ),
+ tester,
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('media_video_error')), findsOneWidget);
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+ });
+
+ testWidgets('passes overlay to local video player when downloaded', (tester) async {
+ setUpFakeVideoPlayerPlatform();
+ final tempDir = Directory.systemTemp.createTempSync('media_video_overlay_test');
+ final tempFile = File('${tempDir.path}/test.mp4');
+ tempFile.writeAsBytesSync([0, 0, 0, 0]);
+ addTearDown(() => tempDir.deleteSync(recursive: true));
+
+ await mountWidget(
+ MediaVideo(
+ mediaFile: _mediaFile(filePath: tempFile.path),
+ overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
+ ),
+ tester,
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('video_player')), findsOneWidget);
+ expect(find.byKey(const Key('test_overlay')), findsOneWidget);
+ });
});
}
diff --git a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift
index 4cae43e..4305cfd 100644
--- a/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/widgetbook/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -10,6 +10,7 @@ import emoji_picker_flutter
import file_selector_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
+import gal
import mobile_scanner
import package_info_plus
import share_plus
@@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
+ GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
diff --git a/widgetbook/pubspec.lock b/widgetbook/pubspec.lock
index 5982d47..5d8582f 100644
--- a/widgetbook/pubspec.lock
+++ b/widgetbook/pubspec.lock
@@ -541,6 +541,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
+ gal:
+ dependency: transitive
+ description:
+ name: gal
+ sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.3.2"
gap:
dependency: transitive
description:
diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc
index 3490e12..7da05be 100644
--- a/windows/flutter/generated_plugin_registrant.cc
+++ b/windows/flutter/generated_plugin_registrant.cc
@@ -10,6 +10,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -23,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
+ GalPluginCApiRegisterWithRegistrar(
+ registry->GetRegistrarForPlugin("GalPluginCApi"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake
index 2c3b8e1..2c4d155 100644
--- a/windows/flutter/generated_plugins.cmake
+++ b/windows/flutter/generated_plugins.cmake
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
emoji_picker_flutter
file_selector_windows
flutter_secure_storage_windows
+ gal
permission_handler_windows
share_plus
url_launcher_windows