Files
noo/distribution/store-assets/tools/capture_test.dart
T
Ayushya AmitabhandClaude Sonnet 5.5 5837464581 Store assets: landscape tablet screenshots (iPad, Android 7/10-inch)
Captured from the app's real sidebar shell at 1024x768; adds landscape
compositions to compose.py and the README upload table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 21:46:29 -04:00

611 lines
22 KiB
Dart

// 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/main.dart' show MainShellView;
import 'package:noo/providers/recent_controller.dart';
import 'package:noo/providers/shares_controller.dart';
import 'package:noo/providers/trash_controller.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<SavedAccount> 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<bool> attemptUnlock() async => false;
}
class DemoOps extends ItemOperations {
const DemoOps({
required super.session,
required super.files,
required super.photos,
required super.favorites,
});
@override
Future<List<NextcloudShare>> 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<List<NextcloudShare>> fetchInheritedShares(NextcloudItem item) async =>
[];
@override
Future<List<NextcloudSharee>> searchSharees(String query) async => [];
}
/// The shell collapses to the Offline tab alone when it thinks there is no
/// network, so the landscape (full shell) captures report an online device.
class DemoConnectivity extends ConnectivityController {
@override
bool get isOffline => false;
}
class DemoFiles extends FilesController {
DemoFiles(super.session, {this.external = false});
final bool external;
@override
NextcloudUserQuota? get quota => const NextcloudUserQuota(
usedBytes: 46000000000,
totalBytes: 107374182400,
usagePercentage: 0.43,
userName: 'alex',
email: 'alex@cloud.example.com',
serverVersion: '31.0.0',
);
@override
StorageScope get storageScope =>
external ? StorageScope.all : StorageScope.cloud;
@override
List<NextcloudItem> 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<void> refreshData() async {}
}
class DemoOffline extends OfflineController {
DemoOffline(super.session, super.files);
@override
List<NextcloudItem> 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<String> 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<NextcloudItem> 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<HttpClientRequest> 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<HttpClientResponse> 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<List<int>> implements HttpClientResponse {
final List<int> 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<List<int>> listen(
void Function(List<int>)? onData, {
Function? onError,
void Function()? onDone,
bool? cancelOnError,
}) => Stream<List<int>>.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' ? <String, String>{} : null,
);
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
(call) async => call.method == 'check' ? <String>['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) {}),
);
}
// The shell listens for share/pick intents; none ever arrive here.
for (final name in [
'dev.ayushya.noo/share_intent/new',
'dev.ayushya.noo/pick_intent/new',
]) {
messenger.setMockStreamHandler(
EventChannel(name),
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
}
messenger.setMockStreamHandler(
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
});
// `ipadl` / `atabletl` are the landscape tablet captures, which render the
// app's real shell (sidebar + toolbar) instead of the single-screen widgets.
for (final device in [
'android',
'atablet',
'atabletl',
'iphone',
'ipad',
'ipadl',
]) {
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,
// Skips the shell's one-time notification-permission prompt.
'notification_permission_asked': true,
});
final landscape = device == 'ipadl' || device == 'atabletl';
final tablet = landscape || device == 'ipad' || device == 'atablet';
final android =
device == 'android' ||
device == 'atablet' ||
device == 'atabletl';
final size = landscape
? const Size(1024, 768)
: 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 = landscape
? DemoConnectivity()
: 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 trash = TrashController(session);
final shares = SharesController(session);
final recent = RecentController(session);
if (landscape && screen == 'offline') {
settings.requestTab(AppTab.offline);
}
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<ConnectivityController>.value(
value: connectivity,
),
ChangeNotifierProvider<SessionController>.value(value: session),
ChangeNotifierProvider<SettingsController>.value(
value: settings,
),
ChangeNotifierProvider<FilesController>.value(value: files),
ChangeNotifierProvider<SyncStatusController>.value(value: sync),
ChangeNotifierProvider<OfflineController>.value(value: offline),
ChangeNotifierProvider<PickController>.value(value: pick),
ChangeNotifierProvider<PhotosController>.value(value: photos),
ChangeNotifierProvider<FavoritesController>.value(
value: favorites,
),
ChangeNotifierProvider<TrashController>.value(value: trash),
ChangeNotifierProvider<SharesController>.value(value: shares),
ChangeNotifierProvider<RecentController>.value(value: recent),
Provider<ItemOperations>.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()
: landscape && browsing
? const MainShellView()
: 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: ConstrainedBox(
// Landscape tablets would stretch the card
// across the whole window; keep it phone-like.
constraints: BoxConstraints(
maxWidth: landscape ? 520 : double.infinity,
),
child: Builder(
builder: (context) => RepaintBoundary(
key: key,
child: ColoredBox(
color: context.nooColors.bg,
child: Padding(
padding: const EdgeInsets.all(12),
// The desktop-style card fills its
// height, so size it to its content.
child: IntrinsicHeight(
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();
trash.dispose();
shares.dispose();
recent.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());
});
}
}
}