Save to gallery (#619)

* feat: add gal depency to save images in gallery

* feat: add save to gallery translations

* feat: add save to gallery hook

* refactor: extract aspect ratio from dim to utils

* refactor: use wn icon button for deleting media in previews

* feat: add overlay to video to then have download button

* feat: save to gallery in media modal

* docs: update changelog

* test: improve coverage of chat message media
This commit is contained in:
Pepi
2026-05-07 08:43:44 +02:00
committed by GitHub
parent 85a42c26cb
commit 61f3e6bc93
50 changed files with 1727 additions and 115 deletions
+422
View File
@@ -0,0 +1,422 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gal/gal.dart';
import 'package:gal/src/gal_platform_interface.dart';
import 'package:whitenoise/hooks/use_save_to_gallery.dart';
import '../test_helpers.dart';
base class _SuccessGalPlatform extends GalPlatform {
@override
Future<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);
});
});
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:whitenoise/utils/aspect_ratio.dart';
void main() {
group('getAspectRatioFromDimensions', () {
group('returns null', () {
test('with null input', () {
expect(getAspectRatioFromDimensions(null), isNull);
});
test('with empty string', () {
expect(getAspectRatioFromDimensions(''), isNull);
});
test('with missing separator', () {
expect(getAspectRatioFromDimensions('1920'), isNull);
});
test('with more than two parts', () {
expect(getAspectRatioFromDimensions('1920x1080x10'), isNull);
});
test('with non-numeric width', () {
expect(getAspectRatioFromDimensions('foox1080'), isNull);
});
test('with non-numeric height', () {
expect(getAspectRatioFromDimensions('1920xbar'), isNull);
});
test('with zero width', () {
expect(getAspectRatioFromDimensions('0x1080'), isNull);
});
test('with zero height', () {
expect(getAspectRatioFromDimensions('1920x0'), isNull);
});
test('with negative width', () {
expect(getAspectRatioFromDimensions('-1920x1080'), isNull);
});
test('with negative height', () {
expect(getAspectRatioFromDimensions('1920x-1080'), isNull);
});
});
group('returns aspect ratio', () {
test('for landscape dimensions', () {
expect(getAspectRatioFromDimensions('1920x1080'), 1920 / 1080);
});
test('for portrait dimensions', () {
expect(getAspectRatioFromDimensions('1080x1920'), 1080 / 1920);
});
test('for square dimensions', () {
expect(getAspectRatioFromDimensions('500x500'), 1.0);
});
test('for decimal dimensions', () {
expect(getAspectRatioFromDimensions('100.5x50.25'), 100.5 / 50.25);
});
});
});
}
+12
View File
@@ -235,6 +235,18 @@ void main() {
expect(find.byKey(const Key('media_image')), findsOneWidget);
});
testWidgets('shows video loading indicator while video is downloading', (tester) async {
_api.downloadCompleter = Completer<MediaFile>();
await mountWidget(
ChatMessageMedia(
mediaFiles: [_mediaFile(mimeType: 'video/mp4', mediaType: 'video')],
),
tester,
);
expect(find.byKey(const Key('media_video_loading_indicator')), findsOneWidget);
});
testWidgets('shows video preview with play indicator on success', (tester) async {
setUpFakeVideoPlayerPlatform();
final tempDir = Directory.systemTemp.createTempSync('chat_media_video_test');
+52
View File
@@ -130,6 +130,58 @@ void main() {
expect(disposeIndex, lessThan(secondCreateIndex));
expect(fakeVideoPlatform.dataSources.last.uri, contains('clip2.mp4'));
});
testWidgets('renders overlay at fallback position before initialization', (tester) async {
fakeVideoPlatform.forceInitError = true;
await mountWidget(
LocalVideoPlayer(
filePath: videoFile.path,
overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
),
tester,
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
});
testWidgets('renders overlay inside video bounds after initialization', (tester) async {
await mountWidget(
LocalVideoPlayer(
filePath: videoFile.path,
overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
),
tester,
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player')), findsOneWidget);
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
});
testWidgets('hides overlay while video is playing, restores when paused', (tester) async {
await mountWidget(
LocalVideoPlayer(
filePath: videoFile.path,
overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
),
tester,
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
await tester.tap(find.byKey(const Key('local_video_tap_area')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('test_overlay')), findsNothing);
await tester.tap(find.byKey(const Key('local_video_tap_area')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
});
});
group('VideoPlayIndicator', () {
+599 -1
View File
@@ -1,20 +1,74 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gal/gal.dart';
import 'package:gal/src/gal_platform_interface.dart';
import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/frb_generated.dart';
import 'package:whitenoise/widgets/media_image.dart';
import 'package:whitenoise/widgets/media_modal.dart';
import 'package:whitenoise/widgets/media_video.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
import 'package:whitenoise/widgets/wn_icon.dart';
import 'package:whitenoise/widgets/wn_icon_button.dart';
import 'package:whitenoise/widgets/wn_overlay.dart';
import 'package:whitenoise/widgets/wn_system_notice.dart';
import '../mocks/mock_wn_api.dart';
import '../test_helpers.dart';
base class _SuccessGalPlatform extends GalPlatform {
@override
Future<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 = '',
String? blurhash,
String? dimensions,
String mimeType = 'image/jpeg',
String mediaType = 'image',
}) => MediaFile(
@@ -29,13 +83,215 @@ MediaFile _mediaFile(
blossomUrl: 'https://example.com/$id',
nostrKey: 'nostr$id',
createdAt: DateTime(2024),
fileMetadata: blurhash != null ? FileMetadata(blurhash: blurhash) : null,
fileMetadata: (blurhash != null || dimensions != null)
? FileMetadata(blurhash: blurhash, dimensions: dimensions)
: null,
);
// Minimal valid 1x1 PNG. Required because Image.file with invalid bytes
// causes the native image codec to hang the test pump loop.
const _minimalPng = <int>[
0x89,
0x50,
0x4E,
0x47,
0x0D,
0x0A,
0x1A,
0x0A,
0x00,
0x00,
0x00,
0x0D,
0x49,
0x48,
0x44,
0x52,
0x00,
0x00,
0x00,
0x01,
0x00,
0x00,
0x00,
0x01,
0x08,
0x02,
0x00,
0x00,
0x00,
0x90,
0x77,
0x53,
0xDE,
0x00,
0x00,
0x00,
0x0C,
0x49,
0x44,
0x41,
0x54,
0x08,
0xD7,
0x63,
0xF8,
0xCF,
0xC0,
0x00,
0x00,
0x00,
0x02,
0x00,
0x01,
0xE2,
0x21,
0xBC,
0x33,
0x00,
0x00,
0x00,
0x00,
0x49,
0x45,
0x4E,
0x44,
0xAE,
0x42,
0x60,
0x82,
];
Future<void> _openAndTapDownload(
WidgetTester tester,
String filePath,
String id,
) async {
await mountWidget(
Builder(
builder: (context) => ElevatedButton(
onPressed: () => MediaModal.show(
context: context,
mediaFiles: [_mediaFile(id, filePath: filePath)],
),
child: const Text('Open'),
),
),
tester,
);
await tester.tap(find.text('Open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const Key('media_modal_download_button_0')));
await tester.pump();
await tester.pump();
await tester.pump();
}
Future<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();
}
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();
}
void main() {
setUpAll(() => RustLib.initMock(api: MockWnApi()));
group('MediaModal', () {
late GalPlatform galPlatform;
setUp(() {
galPlatform = GalPlatform.instance;
});
tearDown(() {
GalPlatform.instance = galPlatform;
});
testWidgets('renders overlay background', (tester) async {
await mountWidget(
Builder(
@@ -356,6 +612,62 @@ void main() {
expect(find.byKey(const Key('media_modal_sender_name')), findsNothing);
});
testWidgets('wraps download button in AspectRatio when image has dimensions', (tester) async {
await mountWidget(
Builder(
builder: (context) => ElevatedButton(
onPressed: () => MediaModal.show(
context: context,
mediaFiles: [_mediaFile('1', dimensions: '1600x900')],
),
child: const Text('Open'),
),
),
tester,
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final aspectRatio = tester.widget<AspectRatio>(
find
.ancestor(
of: find.byKey(const Key('media_modal_download_button_0')),
matching: find.byType(AspectRatio),
)
.first,
);
expect(aspectRatio.aspectRatio, 1600 / 900);
});
testWidgets('does not wrap download button in AspectRatio when image has no dimensions', (
tester,
) async {
await mountWidget(
Builder(
builder: (context) => ElevatedButton(
onPressed: () => MediaModal.show(
context: context,
mediaFiles: [_mediaFile('1')],
),
child: const Text('Open'),
),
),
tester,
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(
find.ancestor(
of: find.byKey(const Key('media_modal_download_button_0')),
matching: find.byType(AspectRatio),
),
findsNothing,
);
});
testWidgets('page view uses NeverScrollableScrollPhysics when zoomed', (tester) async {
await mountWidget(
Builder(
@@ -380,5 +692,291 @@ void main() {
final pageView = tester.widget<PageView>(find.byKey(const Key('media_page_view')));
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');
final button = tester.widget<WnIconButton>(
find.byKey(const Key('media_modal_download_button_0')),
);
expect(button.icon, WnIcons.checkmark);
});
testWidgets('checkmark reverts to download icon after timeout', (tester) async {
GalPlatform.instance = _SuccessGalPlatform();
final dir = Directory.systemTemp.createTempSync('mm_revert');
final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
addTearDown(() => dir.deleteSync(recursive: true));
await _openAndTapDownload(tester, file.path, 'save_c2');
var button = tester.widget<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),
),
);
expect(button.onPressed, isNotNull);
});
testWidgets('long press saves to gallery successfully', (tester) async {
GalPlatform.instance = _SuccessGalPlatform();
final dir = Directory.systemTemp.createTempSync('mm_lp_success');
final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
addTearDown(() => dir.deleteSync(recursive: true));
await _openAndLongPress(tester, file.path, 'lp_s1');
final button = tester.widget<WnIconButton>(
find.byKey(const Key('media_modal_download_button_0')),
);
expect(button.icon, WnIcons.checkmark);
});
testWidgets('long press shows error notice when save fails', (tester) async {
GalPlatform.instance = _ErrorGalPlatform(GalExceptionType.accessDenied);
final dir = Directory.systemTemp.createTempSync('mm_lp_error');
final file = File('${dir.path}/test.png')..writeAsBytesSync(Uint8List.fromList(_minimalPng));
addTearDown(() => dir.deleteSync(recursive: true));
await _openAndLongPress(tester, file.path, 'lp_e1');
expect(find.byType(WnSystemNotice), findsOneWidget);
expect(find.text('Permission denied to save image'), findsOneWidget);
});
testWidgets('long press is disabled when media is not downloaded', (tester) async {
await mountWidget(
Builder(
builder: (context) => ElevatedButton(
onPressed: () => MediaModal.show(
context: context,
mediaFiles: [_mediaFile('lp_d1')],
),
child: const Text('Open'),
),
),
tester,
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final gestureDetector = tester.widget<GestureDetector>(
find.byKey(const Key('media_content_tap_area')),
);
expect(gestureDetector.onLongPress, isNull);
});
testWidgets('shows download button for video media', (tester) async {
await mountWidget(
Builder(
builder: (context) => ElevatedButton(
onPressed: () => MediaModal.show(
context: context,
mediaFiles: [
_mediaFile('vid_btn_1', mimeType: 'video/mp4', mediaType: 'video'),
],
),
child: const Text('Open'),
),
),
tester,
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('media_modal_download_button_0')), findsOneWidget);
});
testWidgets('video download button saves to gallery successfully', (tester) async {
GalPlatform.instance = _VideoSuccessGalPlatform();
final dir = Directory.systemTemp.createTempSync('mm_vid_success');
final file = File('${dir.path}/test.mp4')..writeAsBytesSync([0]);
addTearDown(() => dir.deleteSync(recursive: true));
await _openAndTapDownloadVideo(tester, file.path, 'vid_dl_s1');
final button = tester.widget<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')),
matching: find.byType(WnSystemNotice),
),
findsOneWidget,
);
});
testWidgets('video download button is overlaid on the video player', (tester) async {
await mountWidget(
Builder(
builder: (context) => ElevatedButton(
onPressed: () => MediaModal.show(
context: context,
mediaFiles: [
_mediaFile('vid_ar_1', mimeType: 'video/mp4', mediaType: 'video'),
],
),
child: const Text('Open'),
),
),
tester,
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byKey(const Key('media_modal_download_button_0')), findsOneWidget);
expect(
find.descendant(
of: find.byType(MediaVideo),
matching: find.byKey(const Key('media_modal_download_button_0')),
),
findsOneWidget,
);
});
});
}
+51
View File
@@ -101,5 +101,56 @@ void main() {
expect(find.byKey(const Key('media_video_player')), findsOneWidget);
expect(find.byKey(const Key('video_player')), findsOneWidget);
});
testWidgets('renders overlay on loading placeholder', (tester) async {
_api.downloadCompleter = Completer<MediaFile>();
await mountWidget(
MediaVideo(
mediaFile: _mediaFile(),
overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
),
tester,
);
expect(find.byKey(const Key('media_video_loading')), findsOneWidget);
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
});
testWidgets('renders overlay on error placeholder', (tester) async {
_api.shouldFail = true;
await mountWidget(
MediaVideo(
mediaFile: _mediaFile(),
overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
),
tester,
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('media_video_error')), findsOneWidget);
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
});
testWidgets('passes overlay to local video player when downloaded', (tester) async {
setUpFakeVideoPlayerPlatform();
final tempDir = Directory.systemTemp.createTempSync('media_video_overlay_test');
final tempFile = File('${tempDir.path}/test.mp4');
tempFile.writeAsBytesSync([0, 0, 0, 0]);
addTearDown(() => tempDir.deleteSync(recursive: true));
await mountWidget(
MediaVideo(
mediaFile: _mediaFile(filePath: tempFile.path),
overlay: const SizedBox.square(key: Key('test_overlay'), dimension: 40),
),
tester,
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('video_player')), findsOneWidget);
expect(find.byKey(const Key('test_overlay')), findsOneWidget);
});
});
}