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>
@@ -0,0 +1,69 @@
|
|||||||
|
# Noo store listing assets
|
||||||
|
|
||||||
|
English artwork for the App Store and Google Play, built from Noo's
|
||||||
|
production Flutter widgets with fictional data (example.com servers, made-up
|
||||||
|
people and files). No real accounts, credentials or network requests are
|
||||||
|
involved. The five screenshots follow the Features section of the website
|
||||||
|
(`noo.ayushya.dev`), in the same order:
|
||||||
|
|
||||||
|
1. Multiple accounts: the account switcher
|
||||||
|
2. Locks: the lock screen with the Security card over it
|
||||||
|
3. Offline sync: the Offline tab with the File sync card over it
|
||||||
|
4. Sharing: the Share sheet (people, groups, public link)
|
||||||
|
5. External storage: Files with the mounted-storage section
|
||||||
|
|
||||||
|
Look: the app's own design tokens (warm neutrals, one violet accent,
|
||||||
|
Schibsted Grotesk / Instrument Sans), flat shapes, alternating light and dark.
|
||||||
|
|
||||||
|
## Upload files
|
||||||
|
|
||||||
|
| Store slot | Location | Dimensions |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Google Play app icon | `android/icon-512.png` | 512 × 512 |
|
||||||
|
| Google Play feature graphic | `android/feature-graphic-1024x500.png` | 1024 × 500 |
|
||||||
|
| Google Play phone screenshots | `android/phone/01-*.png` … `05-*.png` | 1080 × 1920 |
|
||||||
|
| Google Play 7-inch tablet screenshots | `android/tablet-7in/` | 1200 × 1920 |
|
||||||
|
| Google Play 10-inch tablet screenshots | `android/tablet-10in/` | 1600 × 2560 |
|
||||||
|
| App Store iPhone screenshots (6.9" / 6.7" slot) | `ios/iphone/` | 1290 × 2796 |
|
||||||
|
| App Store iPad screenshots (13" slot) | `ios/ipad/` | 2064 × 2752 |
|
||||||
|
| iOS icon reference | `ios/icon-1024.png` | 1024 × 1024 |
|
||||||
|
|
||||||
|
Upload in numeric order. The App Store uses the icon embedded in the uploaded
|
||||||
|
build; `ios/icon-1024.png` is only a reference copy. Screenshots, the feature
|
||||||
|
graphic and the iOS icon are opaque RGB PNGs; the Play icon is 32-bit RGBA.
|
||||||
|
`manifest.json` records each file's exact size and mode. `index.html` and
|
||||||
|
`contact-sheet.jpg` are review materials, not store uploads.
|
||||||
|
`store-listing-assets.zip` holds the upload files, this guide, the manifest
|
||||||
|
and the draft listing text.
|
||||||
|
|
||||||
|
`listing-copy.json` has draft text for both stores, written around the same
|
||||||
|
five features. The App Store name is `NooCloud` (the plain name was taken);
|
||||||
|
the home-screen name stays `Noo` via `CFBundleDisplayName`. Character limits
|
||||||
|
are checked by `tools/compose.py`.
|
||||||
|
|
||||||
|
Dimensions checked against the official requirements on October 7, 2026:
|
||||||
|
|
||||||
|
- [Google Play preview assets](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en)
|
||||||
|
- [Apple screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications)
|
||||||
|
|
||||||
|
## Reproduce and edit
|
||||||
|
|
||||||
|
The app needs a current stable Flutter (Dart ^3.12). From the repository root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
flutter test --no-pub distribution/store-assets/tools/capture_test.dart
|
||||||
|
python3 -m venv /tmp/noo-store-assets-venv
|
||||||
|
/tmp/noo-store-assets-venv/bin/pip install Pillow
|
||||||
|
/tmp/noo-store-assets-venv/bin/python distribution/store-assets/tools/compose.py
|
||||||
|
```
|
||||||
|
|
||||||
|
`capture_test.dart` lives outside `test/` so normal test runs never rewrite
|
||||||
|
marketing assets. It renders the real widgets (account switcher, lock screen,
|
||||||
|
Security and File sync sections, Offline tab, Share sheet, Files with
|
||||||
|
external storage) at phone and tablet sizes into `source/`, using demo
|
||||||
|
subclasses of the session, files, sync and item-operation controllers.
|
||||||
|
`compose.py` lays headlines, colors and device panels around those captures;
|
||||||
|
change copy, colors or output sizes there.
|
||||||
|
|
||||||
|
Review the output before uploading, and re-capture after UI changes.
|
||||||
|
These files are prepared locally; no store listing has been changed.
|
||||||
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 306 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<!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><figure><img src="android/phone/01-accounts.png" loading="lazy"><figcaption>android/phone/01-accounts.png · 1080 × 1920</figcaption></figure><figure><img src="android/phone/02-locks.png" loading="lazy"><figcaption>android/phone/02-locks.png · 1080 × 1920</figcaption></figure><figure><img src="android/phone/03-sync.png" loading="lazy"><figcaption>android/phone/03-sync.png · 1080 × 1920</figcaption></figure><figure><img src="android/phone/04-sharing.png" loading="lazy"><figcaption>android/phone/04-sharing.png · 1080 × 1920</figcaption></figure><figure><img src="android/phone/05-external.png" loading="lazy"><figcaption>android/phone/05-external.png · 1080 × 1920</figcaption></figure><figure><img src="android/tablet-7in/01-accounts.png" loading="lazy"><figcaption>android/tablet-7in/01-accounts.png · 1200 × 1920</figcaption></figure><figure><img src="android/tablet-7in/02-locks.png" loading="lazy"><figcaption>android/tablet-7in/02-locks.png · 1200 × 1920</figcaption></figure><figure><img src="android/tablet-7in/03-sync.png" loading="lazy"><figcaption>android/tablet-7in/03-sync.png · 1200 × 1920</figcaption></figure><figure><img src="android/tablet-7in/04-sharing.png" loading="lazy"><figcaption>android/tablet-7in/04-sharing.png · 1200 × 1920</figcaption></figure><figure><img src="android/tablet-7in/05-external.png" loading="lazy"><figcaption>android/tablet-7in/05-external.png · 1200 × 1920</figcaption></figure><figure><img src="android/tablet-10in/01-accounts.png" loading="lazy"><figcaption>android/tablet-10in/01-accounts.png · 1600 × 2560</figcaption></figure><figure><img src="android/tablet-10in/02-locks.png" loading="lazy"><figcaption>android/tablet-10in/02-locks.png · 1600 × 2560</figcaption></figure><figure><img src="android/tablet-10in/03-sync.png" loading="lazy"><figcaption>android/tablet-10in/03-sync.png · 1600 × 2560</figcaption></figure><figure><img src="android/tablet-10in/04-sharing.png" loading="lazy"><figcaption>android/tablet-10in/04-sharing.png · 1600 × 2560</figcaption></figure><figure><img src="android/tablet-10in/05-external.png" loading="lazy"><figcaption>android/tablet-10in/05-external.png · 1600 × 2560</figcaption></figure><figure><img src="ios/iphone/01-accounts.png" loading="lazy"><figcaption>ios/iphone/01-accounts.png · 1290 × 2796</figcaption></figure><figure><img src="ios/iphone/02-locks.png" loading="lazy"><figcaption>ios/iphone/02-locks.png · 1290 × 2796</figcaption></figure><figure><img src="ios/iphone/03-sync.png" loading="lazy"><figcaption>ios/iphone/03-sync.png · 1290 × 2796</figcaption></figure><figure><img src="ios/iphone/04-sharing.png" loading="lazy"><figcaption>ios/iphone/04-sharing.png · 1290 × 2796</figcaption></figure><figure><img src="ios/iphone/05-external.png" loading="lazy"><figcaption>ios/iphone/05-external.png · 1290 × 2796</figcaption></figure><figure><img src="ios/ipad/01-accounts.png" loading="lazy"><figcaption>ios/ipad/01-accounts.png · 2064 × 2752</figcaption></figure><figure><img src="ios/ipad/02-locks.png" loading="lazy"><figcaption>ios/ipad/02-locks.png · 2064 × 2752</figcaption></figure><figure><img src="ios/ipad/03-sync.png" loading="lazy"><figcaption>ios/ipad/03-sync.png · 2064 × 2752</figcaption></figure><figure><img src="ios/ipad/04-sharing.png" loading="lazy"><figcaption>ios/ipad/04-sharing.png · 2064 × 2752</figcaption></figure><figure><img src="ios/ipad/05-external.png" loading="lazy"><figcaption>ios/ipad/05-external.png · 2064 × 2752</figcaption></figure><figure><img src="android/icon-512.png" loading="lazy"><figcaption>android/icon-512.png · 512 × 512</figcaption></figure><figure><img src="ios/icon-1024.png" loading="lazy"><figcaption>ios/icon-1024.png · 1024 × 1024</figcaption></figure><figure><img src="android/feature-graphic-1024x500.png" loading="lazy"><figcaption>android/feature-graphic-1024x500.png · 1024 × 500</figcaption></figure></main></html>
|
||||||
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 268 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"locale": "en-US",
|
||||||
|
"google_play": {
|
||||||
|
"title": "Noo - Nextcloud",
|
||||||
|
"short_description": "The Nextcloud app that just works: accounts, locks, offline sync, sharing.",
|
||||||
|
"description": "Noo is a simple, dependable client for your own Nextcloud server. You host it; Noo makes it effortless for everyone who uses it.\n\nMULTIPLE ACCOUNTS\nKeep your personal server, your work instance and the family box signed in at once and switch in one tap. Each account keeps its own sync scope, cache and favorites.\n\nLOCKS WHERE YOU WANT THEM\nProtect opening the app, switching accounts and revealing hidden files with your device's own PIN, pattern or biometric. Noo never stores or sees your PIN.\n\nOFFLINE AND BACKGROUND SYNC\nMirror a few folders, or the whole account, to your device and keep reading and previewing with no connection. Background sync tops them up, on Wi-Fi only unless you say otherwise, and tells you when something conflicts.\n\nSHARING THAT FEELS LIKE NEXTCLOUD\nInvite people or groups with a permission pill, switch a public link on and copy it, or send the file itself straight to another app.\n\nEXTERNAL STORAGE, BUILT IN\nFolders your admin mounts through Nextcloud's External storage app (SMB, S3, SFTP and more) appear in Files like any other folder.\n\nMAKE IT YOURS\nPin your favorite tabs, order the action bar, choose swipe actions, and adjust the theme and navigation around how you use your cloud.\n\nPRIVATE BY DESIGN\nNoo never asks for your password: sign-in uses your server's own login page (Login Flow v2) and a revocable app token. The app talks only to your server. No analytics, no trackers, no ads.\n\nRequires a Nextcloud server. Noo is an independent, unofficial client; Nextcloud is a trademark of Nextcloud GmbH."
|
||||||
|
},
|
||||||
|
"app_store": {
|
||||||
|
"name": "NooCloud",
|
||||||
|
"subtitle": "The Nextcloud app that works",
|
||||||
|
"promotional_text": "Multiple accounts, app locks, offline sync, sharing and external storage for your own Nextcloud. No analytics, no trackers.",
|
||||||
|
"keywords": "nextcloud,files,photos,offline,sync,self-hosted,storage,cloud,share,webdav",
|
||||||
|
"description": "Noo is a simple, dependable client for your own Nextcloud server. You host it; Noo makes it effortless for everyone who uses it, on iPhone and iPad.\n\nMULTIPLE ACCOUNTS\nKeep your personal server, your work instance and the family box signed in at once and switch in one tap. Each account keeps its own sync scope, cache and favorites.\n\nLOCKS WHERE YOU WANT THEM\nProtect opening the app, switching accounts and revealing hidden files with Face ID, Touch ID or your device passcode. Noo never stores or sees your passcode.\n\nOFFLINE AND BACKGROUND SYNC\nMirror a few folders, or the whole account, to your device and keep opening and previewing files with no connection. iOS decides when background sync can run, so timing is opportunistic rather than on a fixed schedule. Noo tells you when something conflicts.\n\nSHARING THAT FEELS LIKE NEXTCLOUD\nInvite people or groups with a permission pill, switch a public link on and copy it, or send the file itself straight to another app.\n\nEXTERNAL STORAGE, BUILT IN\nFolders your admin mounts through Nextcloud's External storage app (SMB, S3, SFTP and more) appear in Files like any other folder.\n\nMAKE IT YOURS\nPin your favorite tabs, order the action bar, choose swipe actions, and adjust the theme and navigation around how you use your cloud.\n\nPRIVATE BY DESIGN\nNoo never asks for your password: sign-in uses your server's own login page (Login Flow v2) and a revocable app token. The app talks only to your server. No analytics, no trackers, no ads.\n\nRequires a Nextcloud server. Noo is an independent, unofficial client; Nextcloud is a trademark of Nextcloud GmbH."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"file": "android/phone/01-accounts.png",
|
||||||
|
"size": [
|
||||||
|
1080,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/phone/02-locks.png",
|
||||||
|
"size": [
|
||||||
|
1080,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "locks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/phone/03-sync.png",
|
||||||
|
"size": [
|
||||||
|
1080,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/phone/04-sharing.png",
|
||||||
|
"size": [
|
||||||
|
1080,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/phone/05-external.png",
|
||||||
|
"size": [
|
||||||
|
1080,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-7in/01-accounts.png",
|
||||||
|
"size": [
|
||||||
|
1200,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-7in/02-locks.png",
|
||||||
|
"size": [
|
||||||
|
1200,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "locks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-7in/03-sync.png",
|
||||||
|
"size": [
|
||||||
|
1200,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-7in/04-sharing.png",
|
||||||
|
"size": [
|
||||||
|
1200,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-7in/05-external.png",
|
||||||
|
"size": [
|
||||||
|
1200,
|
||||||
|
1920
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-10in/01-accounts.png",
|
||||||
|
"size": [
|
||||||
|
1600,
|
||||||
|
2560
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-10in/02-locks.png",
|
||||||
|
"size": [
|
||||||
|
1600,
|
||||||
|
2560
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "locks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-10in/03-sync.png",
|
||||||
|
"size": [
|
||||||
|
1600,
|
||||||
|
2560
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-10in/04-sharing.png",
|
||||||
|
"size": [
|
||||||
|
1600,
|
||||||
|
2560
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/tablet-10in/05-external.png",
|
||||||
|
"size": [
|
||||||
|
1600,
|
||||||
|
2560
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/iphone/01-accounts.png",
|
||||||
|
"size": [
|
||||||
|
1290,
|
||||||
|
2796
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/iphone/02-locks.png",
|
||||||
|
"size": [
|
||||||
|
1290,
|
||||||
|
2796
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "locks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/iphone/03-sync.png",
|
||||||
|
"size": [
|
||||||
|
1290,
|
||||||
|
2796
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/iphone/04-sharing.png",
|
||||||
|
"size": [
|
||||||
|
1290,
|
||||||
|
2796
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/iphone/05-external.png",
|
||||||
|
"size": [
|
||||||
|
1290,
|
||||||
|
2796
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/ipad/01-accounts.png",
|
||||||
|
"size": [
|
||||||
|
2064,
|
||||||
|
2752
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "accounts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/ipad/02-locks.png",
|
||||||
|
"size": [
|
||||||
|
2064,
|
||||||
|
2752
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "locks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/ipad/03-sync.png",
|
||||||
|
"size": [
|
||||||
|
2064,
|
||||||
|
2752
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sync"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/ipad/04-sharing.png",
|
||||||
|
"size": [
|
||||||
|
2064,
|
||||||
|
2752
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "sharing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/ipad/05-external.png",
|
||||||
|
"size": [
|
||||||
|
2064,
|
||||||
|
2752
|
||||||
|
],
|
||||||
|
"mode": "RGB",
|
||||||
|
"story": "external"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/icon-512.png",
|
||||||
|
"size": [
|
||||||
|
512,
|
||||||
|
512
|
||||||
|
],
|
||||||
|
"mode": "RGBA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "ios/icon-1024.png",
|
||||||
|
"size": [
|
||||||
|
1024,
|
||||||
|
1024
|
||||||
|
],
|
||||||
|
"mode": "RGB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "android/feature-graphic-1024x500.png",
|
||||||
|
"size": [
|
||||||
|
1024,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"mode": "RGB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 288 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -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.')
|
||||||