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());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Compose store artwork from production Flutter widget captures.
|
||||
|
||||
Run from the repository root with Python and Pillow installed (see README).
|
||||
Colors and fonts are the app's own design tokens (warm neutrals, one violet
|
||||
accent, Schibsted Grotesk / Instrument Sans) - flat shapes, no gradients.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import textwrap
|
||||
import zipfile
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
OUT = ROOT / 'distribution/store-assets'
|
||||
FONTS = ROOT / 'assets/google_fonts'
|
||||
|
||||
THEMES = {
|
||||
'light': dict(bg='#F3EFE6', fg='#0E0D0B', fg2='#5A5852', accent='#8D0DE3',
|
||||
accent_text='#8D0DE3', soft='#ECDFFF', line='#D9D5CA', shadow=70),
|
||||
# Dark slides use the `surface` token as the page color so the app's own
|
||||
# near-black `bg` reads as a distinct device screen on top of it.
|
||||
'dark': dict(bg='#25241F', fg='#F3EFE6', fg2='#BDBAB1', accent='#8D0DE3',
|
||||
accent_text='#CBA5FD', soft='#2F0454', line='#5A5852', shadow=150),
|
||||
}
|
||||
|
||||
# The five features on the website's Features section, in the same order.
|
||||
# `back`/`front`: a full-screen capture with an optional settings card
|
||||
# layered over it. Copy sticks to what the app does; no store-specific claims.
|
||||
STORIES = [
|
||||
dict(key='accounts', theme='light', back='accounts', front=None,
|
||||
eyebrow='MULTIPLE ACCOUNTS',
|
||||
lines=('Every cloud,', 'one tap apart.'),
|
||||
sub='Personal, work and family servers together.'),
|
||||
dict(key='locks', theme='dark', back='lock', front='security', card_at=.66,
|
||||
eyebrow='LOCKS',
|
||||
lines=('Locks where', 'you want them.'),
|
||||
sub='Protect opening Noo, switching accounts and hidden files with your device PIN or biometrics.'),
|
||||
dict(key='sync', theme='light', back='offline', front='sync', card_at=.50,
|
||||
eyebrow='OFFLINE SYNC',
|
||||
lines=('Your files,', 'even offline.'),
|
||||
sub='Mirror folders to your device and keep them up to date in the background.'),
|
||||
dict(key='sharing', theme='dark', back='sharing', front=None,
|
||||
eyebrow='SHARING',
|
||||
lines=('Sharing that feels', 'like Nextcloud.'),
|
||||
sub='People, groups and public links in one sheet, or send the file itself.'),
|
||||
dict(key='external', theme='light', back='external', front=None,
|
||||
eyebrow='EXTERNAL STORAGE',
|
||||
lines=('Your NAS, S3 and', 'SFTP, built in.'),
|
||||
sub='Mounted storage shows up in Files like any other folder.'),
|
||||
]
|
||||
|
||||
# (output folder, capture device, canvas size, kind)
|
||||
TARGETS = [
|
||||
('android/phone', 'android', (1080, 1920), 'phone'),
|
||||
('android/tablet-7in', 'atablet', (1200, 1920), 'tablet'),
|
||||
('android/tablet-10in', 'atablet', (1600, 2560), 'tablet'),
|
||||
('ios/iphone', 'iphone', (1290, 2796), 'phone'),
|
||||
('ios/ipad', 'ipad', (2064, 2752), 'tablet'),
|
||||
]
|
||||
|
||||
|
||||
def font(size, weight='SemiBold', family='SchibstedGrotesk'):
|
||||
return ImageFont.truetype(str(FONTS / f'{family}-{weight}.ttf'), size)
|
||||
|
||||
|
||||
def body_font(size, weight='Medium'):
|
||||
return font(size, weight, 'InstrumentSans')
|
||||
|
||||
|
||||
def spaced(draw, xy, text, fnt, fill, tracking):
|
||||
x, y = xy
|
||||
for ch in text:
|
||||
draw.text((x, y), ch, font=fnt, fill=fill)
|
||||
x += draw.textlength(ch, font=fnt) + tracking
|
||||
|
||||
|
||||
def fit(draw, text, make_font, size, max_width):
|
||||
while size > 8 and draw.textlength(text, font=make_font(size)) > max_width:
|
||||
size -= 1
|
||||
return make_font(size), size
|
||||
|
||||
|
||||
def background(size, t):
|
||||
w, h = size
|
||||
im = Image.new('RGB', size, t['bg'])
|
||||
d = ImageDraw.Draw(im)
|
||||
d.ellipse((w * .42, h * .40, w * 1.62, h * 1.22), fill=t['soft'])
|
||||
d.ellipse((-w * .75, h * .70, w * .55, h * 1.40), outline=t['line'], width=max(2, w // 400))
|
||||
return im
|
||||
|
||||
|
||||
def rounded_mask(size, radius):
|
||||
mask = Image.new('L', size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
|
||||
return mask
|
||||
|
||||
|
||||
def content_crop(path, pad=14):
|
||||
"""Crop a settings-card capture to its content (the capture fills the viewport)."""
|
||||
image = Image.open(path).convert('RGB')
|
||||
background = image.getpixel((0, image.height - 1))
|
||||
rows = [y for y in range(image.height)
|
||||
if any(image.getpixel((x, y)) != background for x in range(0, image.width, 6))]
|
||||
return image.crop((0, 0, image.width, min(image.height, rows[-1] + pad)))
|
||||
|
||||
|
||||
def paste_panel(canvas, source, box, radius, t, bleed=False):
|
||||
"""Paste a capture inside box (x, y, w, h), contained unless `bleed`.
|
||||
|
||||
Contained captures are never cropped or distorted. `bleed` scales to the
|
||||
box width and lets the canvas edge clip the bottom (feature graphic only).
|
||||
"""
|
||||
x, y, w, h = box
|
||||
ui = source if isinstance(source, Image.Image) else Image.open(source).convert('RGB')
|
||||
factor = w / ui.width if bleed else min(w / ui.width, h / ui.height)
|
||||
sw, sh = round(ui.width * factor), round(ui.height * factor)
|
||||
x += (w - sw) // 2
|
||||
ui = ui.resize((sw, sh), Image.Resampling.LANCZOS)
|
||||
shadow = Image.new('RGBA', canvas.size, (0, 0, 0, 0))
|
||||
ImageDraw.Draw(shadow).rounded_rectangle(
|
||||
(x, y + radius // 3, x + sw, y + sh + radius // 3), radius=radius, fill=(0, 0, 0, t['shadow']))
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(max(10, canvas.width // 60)))
|
||||
canvas.paste(shadow, (0, 0), shadow)
|
||||
ImageDraw.Draw(canvas).rounded_rectangle(
|
||||
(x - 2, y - 2, x + sw + 1, y + sh + 1), radius=radius + 2, fill=t['line'])
|
||||
canvas.paste(ui, (x, y), rounded_mask((sw, sh), radius))
|
||||
return x, y, sw, sh
|
||||
|
||||
|
||||
def story_canvas(story, size, kind, device):
|
||||
t = THEMES[story['theme']]
|
||||
w, h = size
|
||||
im = background(size, t)
|
||||
d = ImageDraw.Draw(im)
|
||||
margin = round(w * .075)
|
||||
wide = w / h > .64
|
||||
head = round(w * (.060 if kind == 'tablet' and wide else .066 if kind == 'tablet' else .078))
|
||||
y = round(h * .045)
|
||||
spaced(d, (margin, y), story['eyebrow'], font(round(w * .022), 'Bold', 'InstrumentSans'),
|
||||
t['accent_text'], w * .004)
|
||||
y += round(w * .06)
|
||||
for index, line in enumerate(story['lines']):
|
||||
fnt, used = fit(d, line, font, head, w - margin * 2)
|
||||
d.text((margin, y), line, font=fnt, fill=t['accent_text'] if index else t['fg'])
|
||||
y += round(head * 1.12)
|
||||
y += round(head * .18)
|
||||
sub_size = round(w * (.026 if kind == 'tablet' else .033))
|
||||
fnt = body_font(sub_size, 'Regular')
|
||||
chars = max(20, int((w - margin * 2) / (sub_size * .52)))
|
||||
for line in textwrap.wrap(story['sub'], chars):
|
||||
d.text((margin, y), line, font=fnt, fill=t['fg2'])
|
||||
y += round(sub_size * 1.4)
|
||||
area = (margin, y + round(h * .03), w - margin * 2, h - y - round(h * .03) - round(h * .03))
|
||||
src = lambda name: OUT / f'source/{device}/{name}.png'
|
||||
radius = round(w * (.04 if kind == 'tablet' else .052))
|
||||
if story['front']:
|
||||
ax, ay, aw, ah = area
|
||||
bx, by, bw, bh = paste_panel(im, src(story['back']), (ax, ay, int(aw * .82), ah), radius, t)
|
||||
card = content_crop(src(story['front']))
|
||||
cw = round(bw * .94)
|
||||
ch = round(card.height * cw / card.width)
|
||||
cx = min(w - margin - cw, bx + round(bw * .30))
|
||||
cy = by + round(bh * story['card_at'])
|
||||
paste_panel(im, card, (cx, cy, cw, ch), round(radius * .55), t)
|
||||
else:
|
||||
paste_panel(im, src(story['back']), area, radius, t)
|
||||
return im
|
||||
|
||||
|
||||
def check(path, size, mode):
|
||||
image = Image.open(path)
|
||||
assert image.size == tuple(size) and image.mode == mode, path
|
||||
assert path.stat().st_size < 8 * 1024 * 1024, path
|
||||
|
||||
|
||||
manifest = []
|
||||
for folder, device, size, kind in TARGETS:
|
||||
dest = OUT / folder
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for previous in dest.glob('*.png'):
|
||||
previous.unlink()
|
||||
for index, story in enumerate(STORIES, 1):
|
||||
im = story_canvas(story, size, kind, device)
|
||||
path = dest / f'{index:02d}-{story["key"]}.png'
|
||||
im.save(path, optimize=True)
|
||||
manifest.append({'file': str(path.relative_to(OUT)), 'size': list(size), 'mode': 'RGB',
|
||||
'story': story['key']})
|
||||
|
||||
# Store icons: Play wants a 512 px PNG, App Store Connect takes the icon from
|
||||
# the uploaded build (this 1024 px copy is only a reference).
|
||||
for platform, px, source in [
|
||||
('android', 512, ROOT / 'assets/icon/app_icon.png'),
|
||||
('ios', 1024, ROOT / 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png'),
|
||||
]:
|
||||
mode = 'RGBA' if platform == 'android' else 'RGB'
|
||||
path = OUT / f'{platform}/icon-{px}.png'
|
||||
Image.open(source).convert(mode).resize((px, px), Image.Resampling.LANCZOS).save(path, optimize=True)
|
||||
manifest.append({'file': str(path.relative_to(OUT)), 'size': [px, px], 'mode': mode})
|
||||
|
||||
# Play feature graphic (1024 x 500): headline left, two real captures right.
|
||||
t = THEMES['light']
|
||||
fg = background((1024, 500), t)
|
||||
d = ImageDraw.Draw(fg)
|
||||
icon = Image.open(ROOT / 'assets/icon/app_icon.png').convert('RGBA').resize((64, 64), Image.Resampling.LANCZOS)
|
||||
fg.paste(icon, (56, 52), rounded_mask((64, 64), 16))
|
||||
spaced(d, (136, 76), 'NOO', font(22, 'Bold', 'InstrumentSans'), t['accent_text'], 3)
|
||||
d.text((56, 160), 'The Nextcloud app', font=font(54), fill=t['fg'])
|
||||
d.text((56, 222), 'that just works.', font=font(54), fill=t['accent_text'])
|
||||
d.text((58, 312), 'Multiple accounts, locks, offline sync,', font=body_font(22, 'Regular'), fill=t['fg2'])
|
||||
d.text((58, 344), 'sharing and external storage.', font=body_font(22, 'Regular'), fill=t['fg2'])
|
||||
paste_panel(fg, OUT / 'source/android/accounts.png', (560, 70, 220, 560), 26, t, bleed=True)
|
||||
paste_panel(fg, OUT / 'source/android/sharing.png', (770, 120, 220, 560), 26, t, bleed=True)
|
||||
fg.save(OUT / 'android/feature-graphic-1024x500.png', optimize=True)
|
||||
manifest.append({'file': 'android/feature-graphic-1024x500.png', 'size': [1024, 500], 'mode': 'RGB'})
|
||||
|
||||
for asset in manifest:
|
||||
check(OUT / asset['file'], asset['size'], asset['mode'])
|
||||
(OUT / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
|
||||
# Review materials (not for upload): contact sheet and a gallery page.
|
||||
contact = Image.new('RGB', (1920, 1500), '#1c1b18')
|
||||
cd = ImageDraw.Draw(contact)
|
||||
cd.text((32, 20), 'Noo / store listing artwork', font=font(28), fill='#F3EFE6')
|
||||
rows = [('ANDROID PHONE', 'android/phone'), ('ANDROID 10" TABLET', 'android/tablet-10in'),
|
||||
('IPHONE', 'ios/iphone'), ('IPAD', 'ios/ipad')]
|
||||
for row, (label, folder) in enumerate(rows):
|
||||
cd.text((32, 75 + row * 360), label, font=font(15, 'Bold', 'InstrumentSans'), fill='#CBA5FD')
|
||||
for col, path in enumerate(sorted((OUT / folder).glob('*.png'))):
|
||||
thumb = Image.open(path)
|
||||
thumb.thumbnail((330, 330), Image.Resampling.LANCZOS)
|
||||
contact.paste(thumb, (30 + col * 372 + (330 - thumb.width) // 2, 100 + row * 360))
|
||||
contact.save(OUT / 'contact-sheet.jpg', quality=92)
|
||||
|
||||
cards = ''.join(
|
||||
f'<figure><img src="{a["file"]}" loading="lazy"><figcaption>{a["file"]} · {a["size"][0]} × {a["size"][1]}</figcaption></figure>'
|
||||
for a in manifest)
|
||||
(OUT / 'index.html').write_text(
|
||||
'<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||||
'<title>Noo store assets</title><style>body{margin:0;padding:40px;background:#F3EFE6;color:#0E0D0B;font:16px system-ui}'
|
||||
'h1{font-size:36px}p{color:#5A5852}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:24px}'
|
||||
'figure{margin:0}img{width:100%;max-height:680px;object-fit:contain;object-position:top;background:#ECDFFF;border-radius:12px}'
|
||||
'figcaption{padding:12px 0;font-size:13px;color:#8D0DE3}</style><h1>Noo / store listing assets</h1>'
|
||||
'<p>English · production Flutter UI · fictional offline demo data.</p><main>' + cards + '</main></html>')
|
||||
|
||||
# Validate copy limits and package only the assets intended for upload.
|
||||
copy = json.loads((OUT / 'listing-copy.json').read_text())
|
||||
for platform, field, limit in [
|
||||
('google_play', 'title', 30), ('google_play', 'short_description', 80),
|
||||
('google_play', 'description', 4000), ('app_store', 'name', 30),
|
||||
('app_store', 'subtitle', 30), ('app_store', 'promotional_text', 170),
|
||||
('app_store', 'keywords', 100), ('app_store', 'description', 4000)]:
|
||||
assert len(copy[platform][field]) <= limit, (platform, field, len(copy[platform][field]))
|
||||
with zipfile.ZipFile(OUT / 'store-listing-assets.zip', 'w', zipfile.ZIP_DEFLATED) as bundle:
|
||||
for asset in manifest:
|
||||
bundle.write(OUT / asset['file'], asset['file'])
|
||||
for name in ['README.md', 'manifest.json', 'listing-copy.json']:
|
||||
bundle.write(OUT / name, name)
|
||||
print(f'Created and validated {len(manifest)} upload assets; copy limits verified.')
|
||||
Reference in New Issue
Block a user