Store listing assets for the five website features (accounts, locks, offline sync, sharing, external storage)
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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
d92bf04a7d
commit
11b3d535a9
@@ -0,0 +1,533 @@
|
||||
// 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user