iPhone, iPad, Android phone and 7/10-inch tablet screenshots, Play feature graphic and icon, and draft listing copy, generated from real app widgets. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
534 lines
18 KiB
Dart
534 lines
18 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/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 => [];
|
|
}
|
|
|
|
class DemoFiles extends FilesController {
|
|
DemoFiles(super.session, {this.external = false});
|
|
final bool external;
|
|
@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) {}),
|
|
);
|
|
}
|
|
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<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),
|
|
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()
|
|
: 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());
|
|
});
|
|
}
|
|
}
|
|
}
|