Share media and messages via long-press chooser (#687)

This commit is contained in:
Danny M
2026-05-20 21:50:22 +02:00
committed by GitHub
parent 942521fafc
commit 21917327cf
37 changed files with 505 additions and 1298 deletions
+3
View File
@@ -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

-82
View File
@@ -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,
);
}
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -431,10 +431,7 @@
"leaveGroupFetchError": "Не удалось загрузить информацию о группе. Вы не сможете выйти прямо сейчас.",
"failedToLeaveGroup": "Не удалось покинуть группу. Пожалуйста, попробуйте еще раз.",
"youLeftTheGroup": "Вы покинули группу",
"saveToGalleryPermissionDenied": "Разрешение на сохранение изображения отклонено",
"saveToGalleryNotEnoughSpace": "Недостаточно места в хранилище",
"saveToGalleryNotSupportedFormat": "Формат изображения не поддерживается",
"saveToGalleryError": "Не удалось сохранить изображение в галерею",
"shareError": "Не удалось поделиться",
"leaveGroupLastAdminWarning": "Вы единственный администратор в этой группе. Повысьте хотя бы одного участника до администратора, прежде чем выйти.",
"leaveGroupNoCapabilitiesWarning": "Эта группа была создана в более старой версии и не поддерживает выход. Администраторы могут обновить возможности группы, как только все участники перейдут на версию, поддерживающую выход.",
"unsupportedDeepLinkTitle": "Ссылка не поддерживается",
+1 -4
View File
@@ -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
View File
@@ -425,10 +425,7 @@
"notificationsSettingsLoadError": "无法加载通知设置。请重试。",
"notificationsSettingsUpdateError": "无法更新通知设置。请重试。",
"waitingForInternet": "正在等待网络连接",
"saveToGalleryPermissionDenied": "无权保存图片",
"saveToGalleryNotEnoughSpace": "存储空间不足",
"saveToGalleryNotSupportedFormat": "不支持的图片格式",
"saveToGalleryError": "图片保存到相册失败",
"shareError": "分享失败",
"leave": "退出",
"leaveGroup": "退出群组",
"leaveGroupWarning": "确定要退出此群组吗?如果您不删除,聊天记录将保留在列表中,但您将无法发送或接收新消息,除非有人再次邀请您。",
+1 -4
View File
@@ -425,10 +425,7 @@
"notificationsSettingsLoadError": "無法載入通知設定。請再試一次。",
"notificationsSettingsUpdateError": "無法更新通知設定。請再試一次。",
"waitingForInternet": "正在等待網路連線",
"saveToGalleryPermissionDenied": "沒有儲存圖片的權限",
"saveToGalleryNotEnoughSpace": "儲存空間不足",
"saveToGalleryNotSupportedFormat": "不支援此圖片格式",
"saveToGalleryError": "圖片儲存到相簿失敗",
"shareError": "分享失敗",
"leave": "離開",
"leaveGroup": "離開群組",
"leaveGroupWarning": "確定要離開此群組嗎?如果您沒有刪除聊天,它仍會留在列表中;但除非有人再次邀請您,否則您將無法傳送或接收新訊息。",
+3 -21
View File
@@ -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
///
+1 -10
View File
@@ -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';
+1 -10
View File
@@ -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';
+1 -10
View File
@@ -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';
+1 -10
View File
@@ -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';
+1 -10
View File
@@ -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';
+1 -10
View File
@@ -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';
+1 -10
View File
@@ -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 => 'Покинуть';
+1 -10
View File
@@ -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';
+2 -20
View File
@@ -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 => '離開';
+55 -3
View File
@@ -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);
}
+22 -21
View File
@@ -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,
),
],
);
+1
View File
@@ -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"))
-8
View File
@@ -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:
-1
View File
@@ -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
-422
View File
@@ -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);
});
});
}
+115
View File
@@ -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(
+99 -573
View File
@@ -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"))
-8
View File
@@ -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(
-1
View File
@@ -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