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:
Ayushya Amitabh
2026-10-07 21:32:01 -04:00
co-authored by Claude Sonnet 5.5
parent d92bf04a7d
commit 11b3d535a9
64 changed files with 1127 additions and 0 deletions
@@ -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());
});
}
}
}
+258
View File
@@ -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.')