Share media and messages via long-press chooser (#687)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.630645 15.1865C0.730713 15.1879 0.829341 15.1625 0.916354 15.1131C1.00337 15.0637 1.07562 14.9919 1.12564 14.9052C1.87481 13.629 2.94261 12.5692 4.22447 11.8297C5.50633 11.0901 6.95829 10.6962 8.43814 10.6865V14.0615C8.4387 14.1725 8.47211 14.2809 8.53418 14.373C8.59624 14.4651 8.68417 14.5368 8.78689 14.579C8.88933 14.6221 9.00223 14.6338 9.11135 14.6128C9.22047 14.5918 9.32092 14.5389 9.40002 14.4609L16.15 7.71085C16.2027 7.65856 16.2446 7.59635 16.2731 7.5278C16.3017 7.45926 16.3164 7.38574 16.3164 7.31148C16.3164 7.23722 16.3017 7.1637 16.2731 7.09516C16.2446 7.02661 16.2027 6.9644 16.15 6.9121L9.40002 0.162105C9.32092 0.0840536 9.22047 0.0311808 9.11135 0.0101578C9.00223 -0.0108651 8.88933 0.000903879 8.78689 0.0439798C8.68417 0.0861786 8.59624 0.15784 8.53418 0.249933C8.47211 0.342025 8.4387 0.450428 8.43814 0.56148V3.99835C6.11097 4.29726 3.97187 5.43214 2.41952 7.19148C0.867159 8.95081 0.00746053 11.2146 0.000644684 13.5609C0.00197681 13.937 0.0263973 14.3127 0.0737692 14.6859C0.0878193 14.8029 0.138275 14.9125 0.21801 14.9993C0.297745 15.0861 0.402731 15.1457 0.518145 15.1696L0.630645 15.1865ZM8.71939 9.56148C7.28479 9.54088 5.86282 9.83198 4.55173 10.4147C3.24063 10.9974 2.07168 11.8578 1.12564 12.9365C1.29488 10.8959 2.18562 8.98246 3.63808 7.53928C5.09054 6.09609 7.00969 5.21764 9.05127 5.06148C9.19151 5.04881 9.3219 4.98399 9.41668 4.87985C9.51145 4.7757 9.56372 4.63979 9.56314 4.49898V1.9171L14.9575 7.31148L9.56314 12.7059V10.124C9.56314 9.9748 9.50388 9.83172 9.39839 9.72623C9.2929 9.62074 9.14983 9.56148 9.00064 9.56148H8.69689H8.71939Z" fill="#0A0A0A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,82 +0,0 @@
|
||||
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<SaveToGalleryError?>(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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
enum ShareMessageStatus { idle, sharing, error }
|
||||
|
||||
typedef ShareFn = Future<void> Function({Rect? sharePositionOrigin});
|
||||
|
||||
typedef ShareMessageResult = ({
|
||||
ShareMessageStatus status,
|
||||
ShareFn? share,
|
||||
});
|
||||
|
||||
ShareMessageResult useShareMessage({
|
||||
String? text,
|
||||
List<String> filePaths = const [],
|
||||
void Function(Object error)? onError,
|
||||
}) {
|
||||
final status = useState(ShareMessageStatus.idle);
|
||||
|
||||
final trimmedText = text?.trim();
|
||||
final hasText = trimmedText != null && trimmedText.isNotEmpty;
|
||||
final hasFiles = filePaths.isNotEmpty;
|
||||
final canShare = hasText || hasFiles;
|
||||
|
||||
Future<void> share({Rect? sharePositionOrigin}) async {
|
||||
if (!canShare || status.value == ShareMessageStatus.sharing) return;
|
||||
status.value = ShareMessageStatus.sharing;
|
||||
try {
|
||||
final params = ShareParams(
|
||||
text: hasText ? trimmedText : null,
|
||||
files: hasFiles ? filePaths.map(XFile.new).toList() : null,
|
||||
sharePositionOrigin: sharePositionOrigin,
|
||||
);
|
||||
await SharePlus.instance.share(params);
|
||||
status.value = ShareMessageStatus.idle;
|
||||
} catch (e) {
|
||||
status.value = ShareMessageStatus.error;
|
||||
onError?.call(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
status: status.value,
|
||||
share: canShare ? share : null,
|
||||
);
|
||||
}
|
||||
|
||||
List<String> filterExistingFiles(Iterable<String> paths) {
|
||||
return paths.where((p) => p.isNotEmpty && File(p).existsSync()).toList();
|
||||
}
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Beim Laden der Gruppeninfo ist etwas schiefgelaufen. Du kannst die Gruppe gerade nicht verlassen.",
|
||||
"failedToLeaveGroup": "Gruppe konnte nicht verlassen werden. Bitte versuche es erneut.",
|
||||
"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",
|
||||
"shareError": "Teilen fehlgeschlagen",
|
||||
"leaveGroupLastAdminWarning": "Du bist der einzige Admin in dieser Gruppe. Befördere mindestens ein Mitglied zum Admin, bevor du die Gruppe verlassen kannst.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Diese Gruppe wurde mit einer älteren Version erstellt und unterstützt das Verlassen nicht. Admins können die Gruppenkapazitäten upgraden, sobald alle Mitglieder eine Version verwenden, die das Verlassen unterstützt.",
|
||||
"unsupportedDeepLinkTitle": "Link nicht unterstützt",
|
||||
|
||||
+3
-15
@@ -1864,21 +1864,9 @@
|
||||
"@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"
|
||||
"shareError": "Failed to share",
|
||||
"@shareError": {
|
||||
"description": "Error notice shown when sharing a message or media fails"
|
||||
},
|
||||
"leave": "Leave",
|
||||
"@leave": { "description": "Label for the confirm leave button" },
|
||||
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Algo salió mal al cargar la información del grupo. No podrás salir ahora mismo.",
|
||||
"failedToLeaveGroup": "No se pudo salir del grupo. Por favor, inténtalo de nuevo.",
|
||||
"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",
|
||||
"shareError": "Error al compartir",
|
||||
"leaveGroupLastAdminWarning": "Eres el único administrador de este grupo. Promueve al menos a un miembro como administrador antes de poder salir.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Este grupo fue creado con una versión anterior y no admite salir. Los administradores pueden mejorar las capacidades del grupo una vez que todos los miembros estén en una versión que soporte abandonar.",
|
||||
"unsupportedDeepLinkTitle": "Enlace no compatible",
|
||||
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Une erreur s'est produite lors du chargement des infos du groupe. Vous ne pourrez pas quitter pour l'instant.",
|
||||
"failedToLeaveGroup": "Impossible de quitter le groupe. Veuillez réessayer.",
|
||||
"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",
|
||||
"shareError": "Échec du partage",
|
||||
"leaveGroupLastAdminWarning": "Vous êtes le seul administrateur de ce groupe. Promouvez au moins un membre en tant qu'administrateur avant de pouvoir quitter.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Ce groupe a été créé avec une ancienne version et ne prend pas en charge le départ. Les administrateurs peuvent mettre à niveau les capacités du groupe une fois que tous les membres utilisent une version qui prend en charge le départ.",
|
||||
"unsupportedDeepLinkTitle": "Lien non pris en charge",
|
||||
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Si è verificato un errore nel caricamento delle info del gruppo. Non puoi uscire adesso.",
|
||||
"failedToLeaveGroup": "Impossibile lasciare il gruppo. Riprova.",
|
||||
"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",
|
||||
"shareError": "Condivisione non riuscita",
|
||||
"leaveGroupLastAdminWarning": "Sei l'unico amministratore di questo gruppo. Promuovi almeno un membro ad amministratore prima di poter uscire.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Questo gruppo è stato creato con una versione precedente e non supporta l'uscita. Gli amministratori possono aggiornare le capacità del gruppo una volta che tutti i membri utilizzano una versione che supporta l'uscita.",
|
||||
"unsupportedDeepLinkTitle": "Link non supportato",
|
||||
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Algo correu mal ao carregar as informações do grupo. Não poderás sair agora.",
|
||||
"failedToLeaveGroup": "Falha ao sair do grupo. Por favor, tente novamente.",
|
||||
"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",
|
||||
"shareError": "Falha ao compartilhar",
|
||||
"leaveGroupLastAdminWarning": "Você é o único administrador deste grupo. Promova pelo menos um membro a administrador antes de poder sair.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Este grupo foi criado com uma versão mais antiga e não suporta a saída. Os administradores podem atualizar as capacidades do grupo assim que todos os membros estiverem numa versão que suporte sair.",
|
||||
"unsupportedDeepLinkTitle": "Link não suportado",
|
||||
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Не удалось загрузить информацию о группе. Вы не сможете выйти прямо сейчас.",
|
||||
"failedToLeaveGroup": "Не удалось покинуть группу. Пожалуйста, попробуйте еще раз.",
|
||||
"youLeftTheGroup": "Вы покинули группу",
|
||||
"saveToGalleryPermissionDenied": "Разрешение на сохранение изображения отклонено",
|
||||
"saveToGalleryNotEnoughSpace": "Недостаточно места в хранилище",
|
||||
"saveToGalleryNotSupportedFormat": "Формат изображения не поддерживается",
|
||||
"saveToGalleryError": "Не удалось сохранить изображение в галерею",
|
||||
"shareError": "Не удалось поделиться",
|
||||
"leaveGroupLastAdminWarning": "Вы единственный администратор в этой группе. Повысьте хотя бы одного участника до администратора, прежде чем выйти.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Эта группа была создана в более старой версии и не поддерживает выход. Администраторы могут обновить возможности группы, как только все участники перейдут на версию, поддерживающую выход.",
|
||||
"unsupportedDeepLinkTitle": "Ссылка не поддерживается",
|
||||
|
||||
+1
-4
@@ -431,10 +431,7 @@
|
||||
"leaveGroupFetchError": "Grup bilgileri yüklenirken bir sorun oluştu. Şu anda gruptan ayrılamazsınız.",
|
||||
"failedToLeaveGroup": "Gruptan ayrılma başarısız oldu. Lütfen tekrar deneyin.",
|
||||
"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",
|
||||
"shareError": "Paylaşım başarısız",
|
||||
"leaveGroupLastAdminWarning": "Bu grupta tek yöneticisiniz. Ayrılabilmek için en az bir üyeyi yönetici olarak yükseltin.",
|
||||
"leaveGroupNoCapabilitiesWarning": "Bu grup eski bir sürümle oluşturuldu ve ayrılmayı desteklemiyor. Tüm üyeler ayrılmayı destekleyen bir sürüme geçtiğinde, yöneticiler grup özelliklerini yükseltebilir.",
|
||||
"unsupportedDeepLinkTitle": "Bağlantı desteklenmiyor",
|
||||
|
||||
+1
-4
@@ -425,10 +425,7 @@
|
||||
"notificationsSettingsLoadError": "无法加载通知设置。请重试。",
|
||||
"notificationsSettingsUpdateError": "无法更新通知设置。请重试。",
|
||||
"waitingForInternet": "正在等待网络连接",
|
||||
"saveToGalleryPermissionDenied": "无权保存图片",
|
||||
"saveToGalleryNotEnoughSpace": "存储空间不足",
|
||||
"saveToGalleryNotSupportedFormat": "不支持的图片格式",
|
||||
"saveToGalleryError": "图片保存到相册失败",
|
||||
"shareError": "分享失败",
|
||||
"leave": "退出",
|
||||
"leaveGroup": "退出群组",
|
||||
"leaveGroupWarning": "确定要退出此群组吗?如果您不删除,聊天记录将保留在列表中,但您将无法发送或接收新消息,除非有人再次邀请您。",
|
||||
|
||||
@@ -425,10 +425,7 @@
|
||||
"notificationsSettingsLoadError": "無法載入通知設定。請再試一次。",
|
||||
"notificationsSettingsUpdateError": "無法更新通知設定。請再試一次。",
|
||||
"waitingForInternet": "正在等待網路連線",
|
||||
"saveToGalleryPermissionDenied": "沒有儲存圖片的權限",
|
||||
"saveToGalleryNotEnoughSpace": "儲存空間不足",
|
||||
"saveToGalleryNotSupportedFormat": "不支援此圖片格式",
|
||||
"saveToGalleryError": "圖片儲存到相簿失敗",
|
||||
"shareError": "分享失敗",
|
||||
"leave": "離開",
|
||||
"leaveGroup": "離開群組",
|
||||
"leaveGroupWarning": "確定要離開此群組嗎?如果您沒有刪除聊天,它仍會留在列表中;但除非有人再次邀請您,否則您將無法傳送或接收新訊息。",
|
||||
|
||||
@@ -2661,29 +2661,11 @@ abstract class AppLocalizations {
|
||||
/// **'Waiting for internet connection'**
|
||||
String get waitingForInternet;
|
||||
|
||||
/// Error message when permission is denied to save to gallery
|
||||
/// Error notice shown when sharing a message or media fails
|
||||
///
|
||||
/// 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;
|
||||
/// **'Failed to share'**
|
||||
String get shareError;
|
||||
|
||||
/// Label for the confirm leave button
|
||||
///
|
||||
|
||||
@@ -1494,16 +1494,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Teilen fehlgeschlagen';
|
||||
|
||||
@override
|
||||
String get leave => 'Verlassen';
|
||||
|
||||
@@ -1454,16 +1454,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Failed to share';
|
||||
|
||||
@override
|
||||
String get leave => 'Leave';
|
||||
|
||||
@@ -1474,16 +1474,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Error al compartir';
|
||||
|
||||
@override
|
||||
String get leave => 'Salir';
|
||||
|
||||
@@ -1475,16 +1475,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Échec du partage';
|
||||
|
||||
@override
|
||||
String get leave => 'Quitter';
|
||||
|
||||
@@ -1460,16 +1460,7 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Condivisione non riuscita';
|
||||
|
||||
@override
|
||||
String get leave => 'Lascia';
|
||||
|
||||
@@ -1469,16 +1469,7 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Falha ao compartilhar';
|
||||
|
||||
@override
|
||||
String get leave => 'Sair';
|
||||
|
||||
@@ -1490,16 +1490,7 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get waitingForInternet => 'Ожидание подключения к интернету';
|
||||
|
||||
@override
|
||||
String get saveToGalleryPermissionDenied => 'Разрешение на сохранение изображения отклонено';
|
||||
|
||||
@override
|
||||
String get saveToGalleryNotEnoughSpace => 'Недостаточно места в хранилище';
|
||||
|
||||
@override
|
||||
String get saveToGalleryNotSupportedFormat => 'Формат изображения не поддерживается';
|
||||
|
||||
@override
|
||||
String get saveToGalleryError => 'Не удалось сохранить изображение в галерею';
|
||||
String get shareError => 'Не удалось поделиться';
|
||||
|
||||
@override
|
||||
String get leave => 'Покинуть';
|
||||
|
||||
@@ -1457,16 +1457,7 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
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';
|
||||
String get shareError => 'Paylaşım başarısız';
|
||||
|
||||
@override
|
||||
String get leave => 'Ayrıl';
|
||||
|
||||
@@ -1411,16 +1411,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get waitingForInternet => '正在等待网络连接';
|
||||
|
||||
@override
|
||||
String get saveToGalleryPermissionDenied => '无权保存图片';
|
||||
|
||||
@override
|
||||
String get saveToGalleryNotEnoughSpace => '存储空间不足';
|
||||
|
||||
@override
|
||||
String get saveToGalleryNotSupportedFormat => '不支持的图片格式';
|
||||
|
||||
@override
|
||||
String get saveToGalleryError => '图片保存到相册失败';
|
||||
String get shareError => '分享失败';
|
||||
|
||||
@override
|
||||
String get leave => '退出';
|
||||
@@ -2861,16 +2852,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
|
||||
String get waitingForInternet => '正在等待網路連線';
|
||||
|
||||
@override
|
||||
String get saveToGalleryPermissionDenied => '沒有儲存圖片的權限';
|
||||
|
||||
@override
|
||||
String get saveToGalleryNotEnoughSpace => '儲存空間不足';
|
||||
|
||||
@override
|
||||
String get saveToGalleryNotSupportedFormat => '不支援此圖片格式';
|
||||
|
||||
@override
|
||||
String get saveToGalleryError => '圖片儲存到相簿失敗';
|
||||
String get shareError => '分享失敗';
|
||||
|
||||
@override
|
||||
String get leave => '離開';
|
||||
|
||||
@@ -6,7 +6,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:whitenoise/hooks/use_chat_messages.dart' show ChatMessageQuoteData;
|
||||
import 'package:whitenoise/hooks/use_share_message.dart';
|
||||
import 'package:whitenoise/l10n/l10n.dart';
|
||||
import 'package:whitenoise/src/rust/api/media_files.dart' show MediaFile;
|
||||
import 'package:whitenoise/src/rust/api/messages.dart' show ChatMessage;
|
||||
import 'package:whitenoise/theme.dart';
|
||||
import 'package:whitenoise/utils/bubble_grouping.dart' show shouldShowAvatar;
|
||||
@@ -29,7 +31,7 @@ const _modalSectionSpacing = 16.0;
|
||||
const _modalButtonSpacing = 8.0;
|
||||
const _modalContentHorizontalPadding = 14.0;
|
||||
const _modalContentVerticalPadding = 14.0;
|
||||
const _modalPreviewSafetyReserve = 12.0;
|
||||
const _modalPreviewSafetyReserve = 32.0;
|
||||
const _modalMinPreviewHeight = 1.0;
|
||||
const _emojiPickerReservedHeight = 320.0;
|
||||
const _modalToPickerGap = 8.0;
|
||||
@@ -163,6 +165,7 @@ class MessageActionsScreen extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final showEmojiPicker = useState(false);
|
||||
final noticeMessage = useState<String?>(null);
|
||||
|
||||
@@ -182,6 +185,32 @@ class MessageActionsScreen extends HookWidget {
|
||||
);
|
||||
final selectedEmojis = userReactionIds.keys.toSet();
|
||||
|
||||
final cachedMedia = useMemoized(
|
||||
() => _cachedAttachments(message.mediaAttachments),
|
||||
[
|
||||
message.id,
|
||||
message.mediaAttachments.map((m) => m.filePath).join(''),
|
||||
],
|
||||
);
|
||||
final cachedPaths = cachedMedia.map((m) => m.filePath).toList(growable: false);
|
||||
final hasContent = !message.isDeleted && message.content.trim().isNotEmpty;
|
||||
final shareMessage = useShareMessage(
|
||||
text: hasContent ? message.content : null,
|
||||
filePaths: cachedPaths,
|
||||
onError: (_) => showNotice(l10n.shareError),
|
||||
);
|
||||
final shareFn = shareMessage.share;
|
||||
final shareCallback = shareFn == null
|
||||
? null
|
||||
: () async {
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final origin = renderBox != null && renderBox.hasSize
|
||||
? renderBox.localToGlobal(Offset.zero) & renderBox.size
|
||||
: null;
|
||||
await WidgetsBinding.instance.endOfFrame;
|
||||
await shareFn(sharePositionOrigin: origin);
|
||||
};
|
||||
|
||||
Future<void> handleDelete() async {
|
||||
try {
|
||||
await onDelete?.call();
|
||||
@@ -251,6 +280,7 @@ class MessageActionsScreen extends HookWidget {
|
||||
});
|
||||
}
|
||||
: null,
|
||||
onShare: shareCallback,
|
||||
senderName: senderName,
|
||||
senderPictureUrl: senderPictureUrl,
|
||||
isGroupChat: isGroupChat,
|
||||
@@ -286,6 +316,7 @@ class MessageActionsModal extends StatelessWidget {
|
||||
this.onDelete,
|
||||
this.selectedEmojis = const {},
|
||||
this.onReply,
|
||||
this.onShare,
|
||||
this.senderName,
|
||||
this.senderPictureUrl,
|
||||
this.isGroupChat = false,
|
||||
@@ -302,6 +333,7 @@ class MessageActionsModal extends StatelessWidget {
|
||||
final VoidCallback? onDelete;
|
||||
final Set<String> selectedEmojis;
|
||||
final VoidCallback? onReply;
|
||||
final VoidCallback? onShare;
|
||||
final String? senderName;
|
||||
final String? senderPictureUrl;
|
||||
final bool isGroupChat;
|
||||
@@ -327,6 +359,9 @@ class MessageActionsModal extends StatelessWidget {
|
||||
if (onReply != null) {
|
||||
height += _modalButtonSpacing.h + 52.h;
|
||||
}
|
||||
if (onShare != null) {
|
||||
height += _modalButtonSpacing.h + 52.h;
|
||||
}
|
||||
if (onDelete != null) {
|
||||
height += _modalButtonSpacing.h + 52.h;
|
||||
}
|
||||
@@ -397,9 +432,10 @@ class MessageActionsModal extends StatelessWidget {
|
||||
replyPreview != null ||
|
||||
message.mediaAttachments.isNotEmpty ||
|
||||
message.reactions.byEmoji.isNotEmpty;
|
||||
return Align(
|
||||
return UnconstrainedBox(
|
||||
constrainedAxis: Axis.horizontal,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
alignment: isOwnMessage ? Alignment.topRight : Alignment.topLeft,
|
||||
heightFactor: 1,
|
||||
child: ChatMessageBubble(
|
||||
message: message,
|
||||
isOwnMessage: isOwnMessage,
|
||||
@@ -472,6 +508,17 @@ class MessageActionsModal extends StatelessWidget {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
if (onShare != null) ...[
|
||||
Gap(_modalButtonSpacing.h),
|
||||
WnButton(
|
||||
key: const Key('share_button'),
|
||||
text: context.l10n.share,
|
||||
type: WnButtonType.outline,
|
||||
size: WnButtonSize.medium,
|
||||
trailingIcon: WnIcons.share,
|
||||
onPressed: onShare,
|
||||
),
|
||||
],
|
||||
if (onDelete != null) ...[
|
||||
Gap(_modalButtonSpacing.h),
|
||||
WnButton(
|
||||
@@ -532,3 +579,8 @@ class _ReactionButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<MediaFile> _cachedAttachments(List<MediaFile> attachments) {
|
||||
final cachedPaths = filterExistingFiles(attachments.map((m) => m.filePath)).toSet();
|
||||
return attachments.where((m) => cachedPaths.contains(m.filePath)).toList(growable: false);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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_share_message.dart';
|
||||
import 'package:whitenoise/hooks/use_system_notice.dart';
|
||||
import 'package:whitenoise/l10n/l10n.dart';
|
||||
import 'package:whitenoise/providers/locale_provider.dart';
|
||||
@@ -180,24 +180,25 @@ class _MediaContent extends HookWidget {
|
||||
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);
|
||||
},
|
||||
final shareMedia = useShareMessage(
|
||||
filePaths: localPath != null ? [localPath] : const [],
|
||||
onError: (_) => onSaveError(l10n.shareError),
|
||||
);
|
||||
|
||||
Future<void> shareCurrent() async {
|
||||
final shareAction = shareMedia.share;
|
||||
if (shareAction == null) return;
|
||||
final renderBox = context.findRenderObject() as RenderBox?;
|
||||
final sharePositionOrigin = renderBox != null && renderBox.hasSize
|
||||
? renderBox.localToGlobal(Offset.zero) & renderBox.size
|
||||
: null;
|
||||
await shareAction(sharePositionOrigin: sharePositionOrigin);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
key: const Key('media_content_tap_area'),
|
||||
onTap: onTap,
|
||||
onLongPress: localPath != null ? saveToGallery.save : null,
|
||||
onLongPress: localPath != null ? shareCurrent : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -214,10 +215,10 @@ class _MediaContent extends HookWidget {
|
||||
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,
|
||||
final shareButton = WnIconButton(
|
||||
key: Key('media_modal_share_button_$index'),
|
||||
icon: WnIcons.share,
|
||||
onPressed: localPath != null ? shareCurrent : null,
|
||||
type: WnIconButtonType.outline,
|
||||
);
|
||||
|
||||
@@ -225,7 +226,7 @@ class _MediaContent extends HookWidget {
|
||||
return MediaVideo(
|
||||
key: Key('media_video_$index'),
|
||||
mediaFile: mediaFile,
|
||||
overlay: index == currentIndex && showOverlays ? downloadButton : null,
|
||||
overlay: index == currentIndex && showOverlays ? shareButton : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,7 +251,7 @@ class _MediaContent extends HookWidget {
|
||||
Positioned(
|
||||
top: 12.h,
|
||||
right: 12.w,
|
||||
child: downloadButton,
|
||||
child: shareButton,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -261,7 +262,7 @@ class _MediaContent extends HookWidget {
|
||||
Positioned(
|
||||
top: 12.h,
|
||||
right: 12.w,
|
||||
child: downloadButton,
|
||||
child: shareButton,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -83,6 +83,7 @@ enum WnIcons {
|
||||
search('search'),
|
||||
selectText('select_text'),
|
||||
settings('settings'),
|
||||
share('share'),
|
||||
time('time'),
|
||||
trashCan('trash_can'),
|
||||
unarchive('unarchive'),
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -26,7 +25,6 @@ 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"))
|
||||
|
||||
@@ -575,14 +575,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.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:
|
||||
|
||||
@@ -67,7 +67,6 @@ 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
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
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<void> putImage(String path, {String? album}) async {}
|
||||
|
||||
@override
|
||||
Future<void> putVideo(String path, {String? album}) async {}
|
||||
}
|
||||
|
||||
base class _ErrorGalPlatform extends GalPlatform {
|
||||
final GalExceptionType type;
|
||||
_ErrorGalPlatform(this.type);
|
||||
|
||||
@override
|
||||
Future<void> putImage(String path, {String? album}) async {
|
||||
throw GalException(
|
||||
type: type,
|
||||
platformException: PlatformException(code: type.code),
|
||||
stackTrace: StackTrace.current,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<void> putImage(String path, {String? album}) async {
|
||||
throw Exception('Generic error');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putVideo(String path, {String? album}) async {
|
||||
throw Exception('Generic error');
|
||||
}
|
||||
}
|
||||
|
||||
base class _SlowGalPlatform extends GalPlatform {
|
||||
final Completer<void> completer;
|
||||
_SlowGalPlatform(this.completer);
|
||||
|
||||
@override
|
||||
Future<void> putImage(String path, {String? album}) => completer.future;
|
||||
|
||||
@override
|
||||
Future<void> 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<void Function()>());
|
||||
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<SaveToGalleryError>());
|
||||
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<void>();
|
||||
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<void>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:whitenoise/hooks/use_share_message.dart';
|
||||
|
||||
import '../test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('useShareMessage', () {
|
||||
testWidgets('share is null when there is no text and no files', (tester) async {
|
||||
final hook = await mountHook(
|
||||
tester,
|
||||
() => useShareMessage(),
|
||||
);
|
||||
|
||||
expect(hook().share, isNull);
|
||||
expect(hook().status, ShareMessageStatus.idle);
|
||||
});
|
||||
|
||||
testWidgets('share is null when text is only whitespace and no files', (tester) async {
|
||||
final hook = await mountHook(
|
||||
tester,
|
||||
() => useShareMessage(text: ' '),
|
||||
);
|
||||
|
||||
expect(hook().share, isNull);
|
||||
});
|
||||
|
||||
testWidgets('share is callable when text is provided', (tester) async {
|
||||
final hook = await mountHook(
|
||||
tester,
|
||||
() => useShareMessage(text: 'hello'),
|
||||
);
|
||||
|
||||
expect(hook().share, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('share is callable when files are provided', (tester) async {
|
||||
final hook = await mountHook(
|
||||
tester,
|
||||
() => useShareMessage(filePaths: const ['/tmp/x.jpg']),
|
||||
);
|
||||
|
||||
expect(hook().share, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('share is callable when both text and files are provided', (tester) async {
|
||||
final hook = await mountHook(
|
||||
tester,
|
||||
() => useShareMessage(
|
||||
text: 'note',
|
||||
filePaths: const ['/tmp/x.jpg'],
|
||||
),
|
||||
);
|
||||
|
||||
expect(hook().share, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('share stays callable while an earlier share is in flight', (tester) async {
|
||||
final hook = await mountHook(
|
||||
tester,
|
||||
() => useShareMessage(text: 'hi'),
|
||||
);
|
||||
|
||||
// Kick off a share — we never await it because the platform channel
|
||||
// isn't mocked here. We only need to verify the callable reference
|
||||
// doesn't get nulled out while sharing is in flight.
|
||||
unawaited(hook().share!());
|
||||
await tester.pump();
|
||||
|
||||
expect(hook().share, isNotNull);
|
||||
});
|
||||
|
||||
test('ShareMessageStatus enum has expected values', () {
|
||||
expect(ShareMessageStatus.idle.name, 'idle');
|
||||
expect(ShareMessageStatus.sharing.name, 'sharing');
|
||||
expect(ShareMessageStatus.error.name, 'error');
|
||||
});
|
||||
|
||||
test('ShareMessageResult has correct structure', () {
|
||||
final ShareMessageResult result = (
|
||||
status: ShareMessageStatus.idle,
|
||||
share: ({sharePositionOrigin}) async {},
|
||||
);
|
||||
expect(result.status, ShareMessageStatus.idle);
|
||||
expect(result.share, isA<ShareFn>());
|
||||
});
|
||||
|
||||
group('filterExistingFiles', () {
|
||||
test('returns only existing non-empty paths', () async {
|
||||
final tempDir = await Directory.systemTemp.createTemp('filter_test');
|
||||
addTearDown(() => tempDir.delete(recursive: true));
|
||||
final realFile = File('${tempDir.path}/real.txt');
|
||||
await realFile.writeAsString('x');
|
||||
|
||||
final result = filterExistingFiles([
|
||||
'',
|
||||
realFile.path,
|
||||
'${tempDir.path}/does_not_exist.txt',
|
||||
]);
|
||||
|
||||
expect(result, [realFile.path]);
|
||||
});
|
||||
|
||||
test('returns empty list when given no paths', () {
|
||||
expect(filterExistingFiles(const []), isEmpty);
|
||||
});
|
||||
|
||||
test('filters out empty strings', () {
|
||||
expect(filterExistingFiles(const ['', '']), isEmpty);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -476,6 +476,85 @@ void main() {
|
||||
expect(find.byType(ChatMessageBubble), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'preview slot shrinks to bubble size for a short text message',
|
||||
(tester) async {
|
||||
await mountWidget(
|
||||
MessageActionsModal(
|
||||
message: _createTestMessage(content: 'Hello'),
|
||||
isOwnMessage: true,
|
||||
onReaction: (_) {},
|
||||
onEmojiPicker: () {},
|
||||
currentUserPubkey: testPubkeyA,
|
||||
onReply: () {},
|
||||
onShare: () {},
|
||||
onDelete: () {},
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
final bubbleSize = tester.getSize(find.byType(ChatMessageBubble));
|
||||
final viewportHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio;
|
||||
// Bubble for "Hello" should be small — well under half the viewport.
|
||||
expect(bubbleSize.height, lessThan(viewportHeight / 4));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'preview does not throw overflow for media-only message in hold menu',
|
||||
(tester) async {
|
||||
await mountWidget(
|
||||
MessageActionsModal(
|
||||
message: _createTestMessage(
|
||||
content: '',
|
||||
mediaAttachments: [_mediaFile('marmot')],
|
||||
),
|
||||
isOwnMessage: true,
|
||||
onReaction: (_) {},
|
||||
onEmojiPicker: () {},
|
||||
currentUserPubkey: testPubkeyA,
|
||||
onReply: () {},
|
||||
onShare: () {},
|
||||
onDelete: () {},
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'preview does not throw overflow when all action rows are present',
|
||||
(tester) async {
|
||||
final longContent = List.filled(200, 'token').join(' ');
|
||||
await mountWidget(
|
||||
MessageActionsModal(
|
||||
message: _createTestMessage(
|
||||
content: longContent,
|
||||
mediaAttachments: [_mediaFile('1'), _mediaFile('2')],
|
||||
reactions: ReactionSummary(
|
||||
byEmoji: [EmojiReaction(emoji: '🔥', count: BigInt.from(3), users: const [])],
|
||||
userReactions: const [],
|
||||
),
|
||||
),
|
||||
isOwnMessage: true,
|
||||
onReaction: (_) {},
|
||||
onEmojiPicker: () {},
|
||||
currentUserPubkey: testPubkeyA,
|
||||
onReply: () {},
|
||||
onShare: () {},
|
||||
onDelete: () {},
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Copy button', () {
|
||||
@@ -531,6 +610,59 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('Share button', () {
|
||||
testWidgets('is visible when onShare is provided', (tester) async {
|
||||
await mountWidget(
|
||||
MessageActionsModal(
|
||||
message: _createTestMessage(),
|
||||
isOwnMessage: false,
|
||||
onReaction: (_) {},
|
||||
onEmojiPicker: () {},
|
||||
currentUserPubkey: testPubkeyA,
|
||||
onShare: () {},
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('share_button')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('is hidden when onShare is null', (tester) async {
|
||||
await mountWidget(
|
||||
MessageActionsModal(
|
||||
message: _createTestMessage(),
|
||||
isOwnMessage: false,
|
||||
onReaction: (_) {},
|
||||
onEmojiPicker: () {},
|
||||
currentUserPubkey: testPubkeyA,
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('share_button')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('calls onShare when tapped', (tester) async {
|
||||
var shareCalled = false;
|
||||
await mountWidget(
|
||||
MessageActionsModal(
|
||||
message: _createTestMessage(),
|
||||
isOwnMessage: false,
|
||||
onReaction: (_) {},
|
||||
onEmojiPicker: () {},
|
||||
currentUserPubkey: testPubkeyA,
|
||||
onShare: () => shareCalled = true,
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('share_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(shareCalled, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Delete button', () {
|
||||
testWidgets('is visible when onDelete is provided', (tester) async {
|
||||
await mountWidget(
|
||||
|
||||
@@ -3,67 +3,20 @@ 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_share_plus.dart';
|
||||
import '../mocks/mock_wn_api.dart';
|
||||
import '../test_helpers.dart';
|
||||
|
||||
base class _SuccessGalPlatform extends GalPlatform {
|
||||
@override
|
||||
Future<void> putImage(String path, {String? album}) async {}
|
||||
}
|
||||
|
||||
base class _ErrorGalPlatform extends GalPlatform {
|
||||
final GalExceptionType type;
|
||||
_ErrorGalPlatform(this.type);
|
||||
|
||||
@override
|
||||
Future<void> 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<void> putImage(String path, {String? album}) async {
|
||||
throw Exception('Generic error');
|
||||
}
|
||||
}
|
||||
|
||||
base class _VideoSuccessGalPlatform extends GalPlatform {
|
||||
@override
|
||||
Future<void> putVideo(String path, {String? album}) async {}
|
||||
}
|
||||
|
||||
base class _VideoErrorGalPlatform extends GalPlatform {
|
||||
final GalExceptionType type;
|
||||
_VideoErrorGalPlatform(this.type);
|
||||
|
||||
@override
|
||||
Future<void> putVideo(String path, {String? album}) async {
|
||||
throw GalException(
|
||||
type: type,
|
||||
platformException: PlatformException(code: type.code),
|
||||
stackTrace: StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MediaFile _mediaFile(
|
||||
String id, {
|
||||
String filePath = '',
|
||||
@@ -162,17 +115,13 @@ const _minimalPng = <int>[
|
||||
0x82,
|
||||
];
|
||||
|
||||
Future<void> _openAndTapDownload(
|
||||
WidgetTester tester,
|
||||
String filePath,
|
||||
String id,
|
||||
) async {
|
||||
Future<void> _openModal(WidgetTester tester, List<MediaFile> files) async {
|
||||
await mountWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => MediaModal.show(
|
||||
context: context,
|
||||
mediaFiles: [_mediaFile(id, filePath: filePath)],
|
||||
mediaFiles: files,
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
@@ -183,151 +132,34 @@ Future<void> _openAndTapDownload(
|
||||
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<void> _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();
|
||||
File _writeTempPng(String prefix) {
|
||||
final dir = Directory.systemTemp.createTempSync(prefix);
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
return File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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();
|
||||
File _writeTempVideo(String prefix) {
|
||||
final dir = Directory.systemTemp.createTempSync(prefix);
|
||||
addTearDown(() => dir.deleteSync(recursive: true));
|
||||
return File('${dir.path}/test.mp4')..writeAsBytesSync([0]);
|
||||
}
|
||||
|
||||
void main() {
|
||||
setUpAll(() => RustLib.initMock(api: MockWnApi()));
|
||||
|
||||
group('MediaModal', () {
|
||||
late GalPlatform galPlatform;
|
||||
|
||||
setUp(() {
|
||||
galPlatform = GalPlatform.instance;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
GalPlatform.instance = galPlatform;
|
||||
});
|
||||
tearDown(clearSharePlusMock);
|
||||
|
||||
testWidgets('renders overlay background', (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();
|
||||
await _openModal(tester, [_mediaFile('1')]);
|
||||
|
||||
expect(find.byType(WnOverlay), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders modal with single media', (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();
|
||||
await _openModal(tester, [_mediaFile('1')]);
|
||||
|
||||
expect(find.byKey(const Key('media_page_view')), findsOneWidget);
|
||||
expect(find.byKey(const Key('media_modal_slate')), findsOneWidget);
|
||||
@@ -335,48 +167,16 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('renders video media with video viewer', (tester) async {
|
||||
await mountWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => MediaModal.show(
|
||||
context: context,
|
||||
mediaFiles: [
|
||||
_mediaFile(
|
||||
'1',
|
||||
mimeType: 'video/mp4',
|
||||
mediaType: 'video',
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
await _openModal(tester, [
|
||||
_mediaFile('1', mimeType: 'video/mp4', mediaType: 'video'),
|
||||
]);
|
||||
|
||||
expect(find.byType(MediaVideo), findsOneWidget);
|
||||
expect(find.byKey(const Key('media_image_0')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('renders thumbnail strip for multiple media', (tester) async {
|
||||
await mountWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => MediaModal.show(
|
||||
context: context,
|
||||
mediaFiles: [_mediaFile('1'), _mediaFile('2'), _mediaFile('3')],
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
await _openModal(tester, [_mediaFile('1'), _mediaFile('2'), _mediaFile('3')]);
|
||||
|
||||
expect(find.byKey(const Key('media_thumbnail_strip')), findsOneWidget);
|
||||
expect(find.byKey(const Key('thumbnail_0')), findsOneWidget);
|
||||
@@ -407,20 +207,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('displays localized unknown user when no sender name', (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 _openModal(tester, [_mediaFile('1')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Unknown user'), findsOneWidget);
|
||||
@@ -449,20 +236,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('close button pops modal', (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 _openModal(tester, [_mediaFile('1')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('media_modal_slate')), findsOneWidget);
|
||||
@@ -495,20 +269,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('tapping thumbnail navigates to that image', (tester) async {
|
||||
await mountWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => MediaModal.show(
|
||||
context: context,
|
||||
mediaFiles: [_mediaFile('1'), _mediaFile('2'), _mediaFile('3')],
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await _openModal(tester, [_mediaFile('1'), _mediaFile('2'), _mediaFile('3')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('thumbnail_2')));
|
||||
@@ -518,22 +279,9 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows error placeholder for media with empty filePath', (tester) async {
|
||||
await mountWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => MediaModal.show(
|
||||
context: context,
|
||||
mediaFiles: [
|
||||
_mediaFile('1', blurhash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'),
|
||||
],
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await _openModal(tester, [
|
||||
_mediaFile('1', blurhash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj'),
|
||||
]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('media_image_error')), findsOneWidget);
|
||||
@@ -563,20 +311,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('avatar uses neutral color when senderPubkey is null', (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 _openModal(tester, [_mediaFile('1')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final avatar = tester.widget<WnAvatar>(find.byType(WnAvatar));
|
||||
@@ -612,27 +347,14 @@ 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'));
|
||||
testWidgets('wraps share button in AspectRatio when image has dimensions', (tester) async {
|
||||
await _openModal(tester, [_mediaFile('1', dimensions: '1600x900')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final aspectRatio = tester.widget<AspectRatio>(
|
||||
find
|
||||
.ancestor(
|
||||
of: find.byKey(const Key('media_modal_download_button_0')),
|
||||
of: find.byKey(const Key('media_modal_share_button_0')),
|
||||
matching: find.byType(AspectRatio),
|
||||
)
|
||||
.first,
|
||||
@@ -640,28 +362,15 @@ void main() {
|
||||
expect(aspectRatio.aspectRatio, 1600 / 900);
|
||||
});
|
||||
|
||||
testWidgets('does not wrap download button in AspectRatio when image has no dimensions', (
|
||||
testWidgets('does not wrap share 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 _openModal(tester, [_mediaFile('1')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byKey(const Key('media_modal_download_button_0')),
|
||||
of: find.byKey(const Key('media_modal_share_button_0')),
|
||||
matching: find.byType(AspectRatio),
|
||||
),
|
||||
findsNothing,
|
||||
@@ -669,20 +378,7 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('page view uses NeverScrollableScrollPhysics when zoomed', (tester) async {
|
||||
await mountWidget(
|
||||
Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => MediaModal.show(
|
||||
context: context,
|
||||
mediaFiles: [_mediaFile('1'), _mediaFile('2')],
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
tester,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await _openModal(tester, [_mediaFile('1'), _mediaFile('2')]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final mediaImage = tester.widget<MediaImage>(find.byKey(const Key('media_image_0')));
|
||||
@@ -693,178 +389,52 @@ void main() {
|
||||
expect(pageView.physics, isA<NeverScrollableScrollPhysics>());
|
||||
});
|
||||
|
||||
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');
|
||||
testWidgets('share button is disabled when media is not downloaded', (tester) async {
|
||||
await _openModal(tester, [_mediaFile('share_d1')]);
|
||||
|
||||
final button = tester.widget<WnIconButton>(
|
||||
find.byKey(const Key('media_modal_download_button_0')),
|
||||
find.byKey(const Key('media_modal_share_button_0')),
|
||||
);
|
||||
expect(button.icon, WnIcons.checkmark);
|
||||
expect(button.onPressed, isNull);
|
||||
});
|
||||
|
||||
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));
|
||||
testWidgets('share button is enabled when media is downloaded', (tester) async {
|
||||
final file = _writeTempPng('mm_share_enabled');
|
||||
|
||||
await _openAndTapDownload(tester, file.path, 'save_c2');
|
||||
await _openModal(tester, [_mediaFile('share_e1', filePath: file.path)]);
|
||||
|
||||
var button = tester.widget<WnIconButton>(
|
||||
find.byKey(const Key('media_modal_download_button_0')),
|
||||
);
|
||||
expect(button.icon, WnIcons.checkmark);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
|
||||
button = tester.widget<WnIconButton>(
|
||||
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<FilledButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('media_modal_download_button_0')),
|
||||
matching: find.byType(FilledButton),
|
||||
),
|
||||
final button = tester.widget<WnIconButton>(
|
||||
find.byKey(const Key('media_modal_share_button_0')),
|
||||
);
|
||||
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));
|
||||
testWidgets('tapping share button invokes SharePlus', (tester) async {
|
||||
final calls = mockSharePlus();
|
||||
final file = _writeTempPng('mm_share_tap');
|
||||
|
||||
await _openAndLongPress(tester, file.path, 'lp_s1');
|
||||
await _openModal(tester, [_mediaFile('share_t1', filePath: file.path)]);
|
||||
|
||||
final button = tester.widget<WnIconButton>(
|
||||
find.byKey(const Key('media_modal_download_button_0')),
|
||||
);
|
||||
expect(button.icon, WnIcons.checkmark);
|
||||
await tester.tap(find.byKey(const Key('media_modal_share_button_0')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, isNotEmpty);
|
||||
});
|
||||
|
||||
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));
|
||||
testWidgets('long press invokes SharePlus when media is downloaded', (tester) async {
|
||||
final calls = mockSharePlus();
|
||||
final file = _writeTempPng('mm_lp_share');
|
||||
|
||||
await _openAndLongPress(tester, file.path, 'lp_e1');
|
||||
await _openModal(tester, [_mediaFile('lp_share_1', filePath: file.path)]);
|
||||
|
||||
expect(find.byType(WnSystemNotice), findsOneWidget);
|
||||
expect(find.text('Permission denied to save image'), findsOneWidget);
|
||||
await tester.longPress(find.byKey(const Key('media_content_tap_area')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, isNotEmpty);
|
||||
});
|
||||
|
||||
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();
|
||||
await _openModal(tester, [_mediaFile('lp_d1')]);
|
||||
|
||||
final gestureDetector = tester.widget<GestureDetector>(
|
||||
find.byKey(const Key('media_content_tap_area')),
|
||||
@@ -872,75 +442,15 @@ void main() {
|
||||
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,
|
||||
);
|
||||
testWidgets('share failure shows error notice inside the slate', (tester) async {
|
||||
mockSharePlusFailing();
|
||||
final file = _writeTempPng('mm_share_err');
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await _openModal(tester, [_mediaFile('share_err_1', filePath: file.path)]);
|
||||
|
||||
await tester.tap(find.byKey(const Key('media_modal_share_button_0')));
|
||||
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<WnIconButton>(
|
||||
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<WnIconButton>(
|
||||
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')),
|
||||
@@ -948,32 +458,48 @@ void main() {
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Failed to share'), 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'));
|
||||
testWidgets('shows share button for video media', (tester) async {
|
||||
await _openModal(tester, [
|
||||
_mediaFile('vid_btn_1', mimeType: 'video/mp4', mediaType: 'video'),
|
||||
]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('media_modal_download_button_0')), findsOneWidget);
|
||||
expect(find.byKey(const Key('media_modal_share_button_0')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('video share button invokes SharePlus when media is downloaded', (tester) async {
|
||||
final calls = mockSharePlus();
|
||||
final file = _writeTempVideo('mm_vid_share');
|
||||
|
||||
await _openModal(tester, [
|
||||
_mediaFile(
|
||||
'vid_share_1',
|
||||
filePath: file.path,
|
||||
mimeType: 'video/mp4',
|
||||
mediaType: 'video',
|
||||
),
|
||||
]);
|
||||
|
||||
await tester.tap(find.byKey(const Key('media_modal_share_button_0')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(calls, isNotEmpty);
|
||||
});
|
||||
|
||||
testWidgets('share button is overlaid on the video player', (tester) async {
|
||||
await _openModal(tester, [
|
||||
_mediaFile('vid_ar_1', mimeType: 'video/mp4', mediaType: 'video'),
|
||||
]);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('media_modal_share_button_0')), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(MediaVideo),
|
||||
matching: find.byKey(const Key('media_modal_download_button_0')),
|
||||
matching: find.byKey(const Key('media_modal_share_button_0')),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -25,7 +24,6 @@ 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"))
|
||||
|
||||
@@ -541,14 +541,6 @@ 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:
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include <emoji_picker_flutter/emoji_picker_flutter_plugin_c_api.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
@@ -24,8 +23,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -7,7 +7,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
emoji_picker_flutter
|
||||
file_selector_windows
|
||||
flutter_secure_storage_windows
|
||||
gal
|
||||
permission_handler_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
|
||||
Reference in New Issue
Block a user