// Render the production widgets with fictional, offline data. No device // accounts, credentials or server requests are involved in these captures. import 'dart:io'; import 'dart:async'; import 'dart:convert'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:noo/models/app_tab.dart'; import 'package:noo/theme/design_tokens.dart'; import 'package:noo/views/lock_screen_view.dart'; import 'package:noo/widgets/shell/shell_common.dart' show showAccountSwitcher; import 'package:noo/models/nextcloud_share.dart'; import 'package:noo/models/nextcloud_sharee.dart'; import 'package:noo/models/saved_account.dart'; import 'package:noo/widgets/share_sheet.dart'; import 'package:noo/widgets/settings/settings_file_sync.dart'; import 'package:noo/models/nextcloud_item.dart'; import 'package:noo/providers/connectivity_controller.dart'; import 'package:noo/providers/session_controller.dart'; import 'package:noo/providers/settings_controller.dart'; import 'package:noo/providers/files_controller.dart'; import 'package:noo/providers/offline_controller.dart'; import 'package:noo/providers/sync_status_controller.dart'; import 'package:noo/providers/pick_controller.dart'; import 'package:noo/providers/photos_controller.dart'; import 'package:noo/providers/favorites_controller.dart'; import 'package:noo/providers/item_operations.dart'; import 'package:noo/theme/app_theme.dart'; import 'package:noo/views/files_view.dart'; import 'package:noo/models/sync_status.dart'; import 'package:noo/widgets/app_top_bar.dart'; import 'package:noo/widgets/bottom_nav_bar.dart'; import 'package:noo/widgets/noo/nav/noo_top_bar.dart'; import 'package:noo/widgets/settings/settings_security.dart'; final demoFiles = [ for (final name in ['Documents', 'Projects', 'Travel']) NextcloudItem( id: name, name: name, path: '/$name', type: NextcloudItemType.folder, size: 0, lastModified: DateTime(2026, 10, 1), ), for (final entry in [ ('Brand brief.pdf', 284000), ('Budget.xlsx', 158000), ('Meeting notes.txt', 2400), ('Roadmap.pdf', 412000), ('Weekend plans.txt', 1800), ]) NextcloudItem( id: entry.$1, name: entry.$1, path: '/${entry.$1}', type: NextcloudItemType.file, size: entry.$2, isFavorite: entry.$1 == 'Roadmap.pdf', lastModified: DateTime(2026, 10, 1), ), ]; final demoExternal = [ for (final name in ['NAS (SMB)', 'Archive (S3)', 'Work drive (SFTP)']) NextcloudItem( id: name, name: name, path: '/$name', type: NextcloudItemType.folder, size: 0, lastModified: DateTime(2026, 10, 1), mountType: 'external', ), ]; class DemoSession extends SessionController { DemoSession(super.connectivity); static const demoAccounts = [ SavedAccount( id: 'a', serverUrl: 'https://cloud.example.com', username: 'alex', ), SavedAccount( id: 'b', serverUrl: 'https://work.example.org', username: 'alex.morgan', ), SavedAccount( id: 'c', serverUrl: 'https://family.example.net', username: 'morgan', ), ]; @override List get accounts => demoAccounts; @override String? get activeAccountId => 'a'; @override SavedAccount? get activeAccount => demoAccounts.first; @override String get serverUrl => 'https://cloud.example.com'; @override String get username => 'alex'; @override bool get isLoggedIn => true; @override Future attemptUnlock() async => false; } class DemoOps extends ItemOperations { const DemoOps({ required super.session, required super.files, required super.photos, required super.favorites, }); @override Future> fetchItemShares(NextcloudItem item) async => [ NextcloudShare( id: '1', path: item.path, name: item.name, itemType: item.type, shareType: ShareType.user, ownerDisplayName: 'Alex', sharedWithDisplayName: 'Ana Souza', sharedAt: DateTime(2026, 10, 2), sharedWithMe: false, permissions: 19, ), NextcloudShare( id: '2', path: item.path, name: item.name, itemType: item.type, shareType: ShareType.group, ownerDisplayName: 'Alex', sharedWithDisplayName: 'Design team', sharedAt: DateTime(2026, 10, 3), sharedWithMe: false, permissions: 17, ), NextcloudShare( id: '3', path: item.path, name: item.name, itemType: item.type, shareType: ShareType.publicLink, ownerDisplayName: 'Alex', sharedAt: DateTime(2026, 10, 4), sharedWithMe: false, url: 'https://cloud.example.com/s/Kp3xQ9vRtLw', token: 'Kp3xQ9vRtLw', permissions: 1, ), ]; @override Future> fetchInheritedShares(NextcloudItem item) async => []; @override Future> searchSharees(String query) async => []; } class DemoFiles extends FilesController { DemoFiles(super.session, {this.external = false}); final bool external; @override StorageScope get storageScope => external ? StorageScope.all : StorageScope.cloud; @override List get items => external ? [...demoFiles.take(2), ...demoFiles.skip(3).take(2), ...demoExternal] : demoFiles; @override bool get isLoading => false; @override String? get errorMessage => null; @override Future refreshData() async {} } class DemoOffline extends OfflineController { DemoOffline(super.session, super.files); @override List get items => demoFiles.take(6).toList(); @override bool get isLoading => false; @override String? get errorMessage => null; } class DemoSync extends SyncStatusController { DemoSync(super.session, super.files); @override bool get syncEverything => false; @override bool get syncNotifications => true; @override bool get syncOnCellular => false; @override List get syncedPaths => ['/Documents', '/Projects', '/Travel']; @override int get syncedFolderCount => 3; @override SyncHeaderStatus get syncHeaderStatus => SyncHeaderStatus.done; } class DemoPhotos extends PhotosController { DemoPhotos(super.session, super.files); @override List get items => [ for (var i = 1; i <= 12; i++) NextcloudItem( id: 'photo$i', name: 'Space $i.jpg', path: '/Photos/Space $i.jpg', type: NextcloudItemType.image, size: 2400000, lastModified: DateTime(2026, 10, i), mimeType: 'image/jpeg', previewUrl: 'https://demo.invalid/${(i - 1) % 6 + 1}.jpg', ), ]; } class DemoHttp implements HttpClient { @override Future getUrl(Uri url) async => DemoRequest(url); @override dynamic noSuchMethod(Invocation invocation) => null; } class DemoRequest implements HttpClientRequest { final Uri url; DemoRequest(this.url); @override HttpHeaders get headers => DemoHeaders(); @override Future close() async => DemoResponse( File( 'distribution/store-assets/source/demo-photos/${url.pathSegments.last.split('.').first.padLeft(2, '0')}.jpg', ).readAsBytesSync(), ); @override dynamic noSuchMethod(Invocation invocation) => null; } class DemoHeaders implements HttpHeaders { @override dynamic noSuchMethod(Invocation invocation) => null; } class DemoResponse extends Stream> implements HttpClientResponse { final List bytes; DemoResponse(this.bytes); @override int get statusCode => 200; @override int get contentLength => bytes.length; @override HttpClientResponseCompressionState get compressionState => HttpClientResponseCompressionState.notCompressed; @override HttpHeaders get headers => DemoHeaders(); @override StreamSubscription> listen( void Function(List)? onData, { Function? onError, void Function()? onDone, bool? cancelOnError, }) => Stream>.value(bytes).listen( onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError, ); @override dynamic noSuchMethod(Invocation invocation) => null; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() async { GoogleFonts.config.allowRuntimeFetching = false; final manifest = jsonDecode(await rootBundle.loadString('FontManifest.json')) as List; for (final entry in manifest) { final loader = FontLoader(entry['family'] as String); for (final font in entry['fonts'] as List) { loader.addFont(rootBundle.load(font['asset'] as String)); } await loader.load(); } final mono = FontLoader('monospace') ..addFont( Future.value( ByteData.sublistView( File( 'assets/google_fonts/InstrumentSans-Medium.ttf', ).readAsBytesSync(), ), ), ); await mono.load(); final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; messenger.setMockMethodCallHandler( const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), (call) async => call.method == 'readAll' ? {} : null, ); messenger.setMockMethodCallHandler( const MethodChannel('dev.fluttercommunity.plus/connectivity'), (call) async => call.method == 'check' ? ['none'] : null, ); for (final name in [ 'dev.ayushya.noo/upload_service/status', 'dev.ayushya.noo/sync_service/status', ]) { messenger.setMockStreamHandler( EventChannel(name), MockStreamHandler.inline(onListen: (arguments, events) {}), ); } messenger.setMockStreamHandler( const EventChannel('dev.fluttercommunity.plus/connectivity_status'), MockStreamHandler.inline(onListen: (arguments, events) {}), ); }); for (final device in ['android', 'atablet', 'iphone', 'ipad']) { for (final screen in [ 'accounts', 'lock', 'security', 'sync', 'offline', 'sharing', 'external', ]) { testWidgets('$device $screen capture', (tester) async { await HttpOverrides.runZoned(() async { // This is an explicitly invoked capture test outside the default test suite. // ignore: invalid_use_of_visible_for_testing_member SharedPreferences.setMockInitialValues({ 'ui_login_lock_enabled': true, 'ui_lock_account_switching': true, 'ui_lock_hidden_files': true, 'ui_use_dynamic_color': false, }); final tablet = device == 'ipad' || device == 'atablet'; final android = device == 'android' || device == 'atablet'; final size = tablet ? const Size(768, 1024) : (android ? const Size(430, 800) : const Size(430, 932)); tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; await tester.binding.setSurfaceSize(size); final key = GlobalKey(); final connectivity = ConnectivityController(); final session = DemoSession(connectivity); final files = DemoFiles(session, external: screen == 'external'); final settings = SettingsController(); final sync = DemoSync(session, files); final offline = DemoOffline(session, files); final pick = PickController(session); final photos = DemoPhotos(session, files); final favorites = FavoritesController(session, files); final ops = DemoOps( session: session, files: files, photos: photos, favorites: favorites, ); final scroll = ScrollController(); final style = android ? NooNavStyle.android : NooNavStyle.ios; final browsing = const { 'external', 'sharing', 'offline', 'accounts', }.contains(screen); // `security` and `sync` are cropped to just their settings card. final card = screen == 'security' || screen == 'sync'; final dark = screen == 'security' || screen == 'sharing' || screen == 'lock'; final theme = (dark ? AppTheme.dark : AppTheme.light)( AppTheme.defaultAccent, useDynamicColor: false, ).copyWith( platform: android ? TargetPlatform.android : TargetPlatform.iOS, ); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value( value: connectivity, ), ChangeNotifierProvider.value(value: session), ChangeNotifierProvider.value( value: settings, ), ChangeNotifierProvider.value(value: files), ChangeNotifierProvider.value(value: sync), ChangeNotifierProvider.value(value: offline), ChangeNotifierProvider.value(value: pick), ChangeNotifierProvider.value(value: photos), Provider.value(value: ops), ], child: MaterialApp( debugShowCheckedModeBanner: false, theme: theme, // Wrapping the Navigator (not just `home`) lets the capture // include modal sheets pushed over the page. builder: (context, child) => card ? child! : RepaintBoundary(key: key, child: child), home: screen == 'lock' ? const LockScreenView() : Scaffold( body: browsing ? FilesView( scrollController: scroll, offline: screen == 'offline', topBar: AppTopBar( style: style, tab: screen == 'offline' ? AppTab.offline : AppTab.files, navMenuStyle: NooNavMenuStyle.avatarMenu, ), ) : Align( alignment: Alignment.topCenter, child: Builder( builder: (context) => RepaintBoundary( key: key, child: ColoredBox( color: context.nooColors.bg, child: Padding( padding: const EdgeInsets.all(12), child: screen == 'security' ? const SettingsSecuritySection() : const SettingsFileSyncSection(), ), ), ), ), ), bottomNavigationBar: browsing ? BottomNavBar( style: style, barStyle: NooBottomBarStyle.floating, tabs: [ AppTab.files, AppTab.photos, AppTab.favorites, AppTab.activity, AppTab.offline, ], selectedIndex: screen == 'offline' ? 4 : 0, onDestinationSelected: (_) {}, ) : null, ), ), ), ); for (var i = 0; i < 12; i++) { await tester.pump(const Duration(milliseconds: 100)); } if (screen == 'accounts') { showAccountSwitcher(tester.element(find.byType(Scaffold).first)); for (var i = 0; i < 15; i++) { await tester.pump(const Duration(milliseconds: 100)); } } if (screen == 'sharing') { // Don't await: the sheet future completes only when it is closed. unawaited( ShareSheet.show( tester.element(find.byType(Scaffold).first), demoFiles.firstWhere((i) => i.name == 'Brand brief.pdf'), ), ); for (var i = 0; i < 15; i++) { await tester.pump(const Duration(milliseconds: 100)); } } await tester.runAsync(() => GoogleFonts.pendingFonts()); await tester.pump(); expect(tester.takeException(), isNull); final boundary = key.currentContext!.findRenderObject()! as RenderRepaintBoundary; await tester.runAsync(() async { final image = await boundary.toImage(pixelRatio: 3); final bytes = await image.toByteData( format: ui.ImageByteFormat.png, ); final directory = Directory( 'distribution/store-assets/source/$device', ); await directory.create(recursive: true); await File( '${directory.path}/$screen.png', ).writeAsBytes(bytes!.buffer.asUint8List()); image.dispose(); }); await tester.pumpWidget(const SizedBox.shrink()); scroll.dispose(); pick.dispose(); offline.dispose(); sync.dispose(); photos.dispose(); favorites.dispose(); files.dispose(); session.dispose(); connectivity.dispose(); settings.dispose(); tester.view.resetPhysicalSize(); tester.view.resetDevicePixelRatio(); await tester.binding.setSurfaceSize(null); }, createHttpClient: (_) => DemoHttp()); }); } } }