import 'dart:io'; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; // ignore: depend_on_referenced_packages import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:ikar/core/media/gallery_source.dart'; import 'package:ikar/frontend/widgets/attachment/attachment_sheet.dart'; import 'package:ikar/l10n/app_localizations.dart'; class FakeGallery implements GallerySource { FakeGallery({ this.permission = GalleryPermission.granted, this.fail = false, this.count = 8, this.hang = false, }); GalleryPermission permission; int requests = 0; int settingsOpened = 0; final bool fail; final int count; final bool hang; @override Future ensurePermission() async { requests++; return permission; } @override Future currentPermission() async => permission; @override Future load({int offset = 0, int limit = 120}) async { if (hang) return Completer().future; if (fail) throw StateError('gallery unavailable'); return GalleryPage( items: List.generate( count, (i) => GalleryItem.fromFile(File('missing-$i.jpg')), ), hasMore: false, ); } @override Future openSettings() async { settingsOpened++; } @override Future manageAccess() async {} } void main() { setUp( () => SharedPreferences.setMockInitialValues({ 'attachment_gallery_access_explained_v1': true, }), ); Future openSheet(WidgetTester tester, FakeGallery source) async { await tester.pumpWidget( MaterialApp( locale: const Locale('ru'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: AttachmentSheet(gallerySource: source)), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); } for (final allow in [true, false]) { testWidgets('first gallery opening explains access, allow=$allow', ( tester, ) async { SharedPreferences.setMockInitialValues({}); final source = FakeGallery(permission: GalleryPermission.denied); await openSheet(tester, source); await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(AlertDialog), findsOneWidget); expect(source.requests, 0); final dialog = find.byType(AlertDialog); await tester.tap( find.descendant( of: dialog, matching: find.byType(allow ? FilledButton : TextButton), ), ); await tester.pumpAndSettle(); expect(source.requests, allow ? 1 : 0); expect(find.byType(AlertDialog), findsNothing); expect( (await SharedPreferences.getInstance()).getBool( 'attachment_gallery_access_explained_v1', ), isTrue, ); }); } testWidgets('full access skips introduction and permission request', ( tester, ) async { SharedPreferences.setMockInitialValues({}); final source = FakeGallery(); await openSheet(tester, source); expect(find.byType(AlertDialog), findsNothing); expect(source.requests, 0); }); testWidgets( 'empty limited gallery retains settings action and refreshes on return', (tester) async { final source = FakeGallery( permission: GalleryPermission.limited, count: 0, ); await openSheet(tester, source); expect( find.textContaining('Доступны только выбранные фото'), findsOneWidget, ); await tester.tap(find.text('Настройки')); expect(source.settingsOpened, 1); source.permission = GalleryPermission.granted; tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); expect( find.textContaining('Доступны только выбранные фото'), findsNothing, ); }, ); testWidgets( 'system picker sends chosen photos when gallery access is denied', (tester) async { final original = ImagePickerPlatform.instance; final picker = FakePicker()..files = [XFile('one.jpg'), XFile('two.jpg')]; ImagePickerPlatform.instance = picker; addTearDown(() => ImagePickerPlatform.instance = original); List? sent; await tester.pumpWidget( MaterialApp( locale: const Locale('ru'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: Builder( builder: (context) => TextButton( onPressed: () => showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => AttachmentSheet( gallerySource: FakeGallery( permission: GalleryPermission.denied, ), onSend: (photos, caption) => sent = photos, ), ), child: const Text('open'), ), ), ), ), ); await tester.tap(find.text('open')); await tester.pumpAndSettle(); await tester.tap(find.widgetWithText(TextButton, 'Камера')); await tester.pumpAndSettle(); expect(picker.source, ImageSource.camera); expect(find.byType(AttachmentSheet), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, 'Галерея')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); await tester.tap(find.byIcon(Symbols.send)); await tester.pumpAndSettle(); expect(sent?.map((photo) => photo.item.localFile?.path), [ 'one.jpg', 'two.jpg', ]); expect(find.byType(AttachmentSheet), findsNothing); }, ); for (final scenario in ['photos', 'empty', 'denied', 'error', 'timeout']) { testWidgets( 'attachment gallery $scenario keeps camera visible and finite', (tester) async { await tester.binding.setSurfaceSize(const Size(400, 850)); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( locale: const Locale('ru'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: AttachmentSheet( gallerySource: FakeGallery( permission: scenario == 'denied' ? GalleryPermission.denied : GalleryPermission.granted, fail: scenario == 'error', hang: scenario == 'timeout', count: scenario == 'empty' ? 0 : 8, ), ), ), ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); if (scenario == 'timeout') { expect(find.widgetWithText(TextButton, 'Камера'), findsOneWidget); await tester.pump(const Duration(seconds: 21)); await tester.pump(); expect(find.text('Не удалось загрузить галерею'), findsOneWidget); } expect(tester.takeException(), isNull); expect(find.widgetWithText(TextButton, 'Камера'), findsOneWidget); expect(find.widgetWithText(TextButton, 'Галерея'), findsOneWidget); expect(find.byType(CustomScrollView), findsOneWidget); }, ); } } class FakePicker extends ImagePickerPlatform { List files = []; ImageSource? source; @override Future> getMedia({required MediaOptions options}) async => files; @override Future getImageFromSource({ required ImageSource source, ImagePickerOptions options = const ImagePickerOptions(), }) async { this.source = source; return null; } }