Rebuild UI on the Noo design system

Replaces the Material 3 screens with the Noo design system: warm
neutrals, one violet accent, pill controls, Schibsted Grotesk/
Instrument Sans, Lucide icons, no gradients/shadows.

- New lib/widgets/noo/ component kit (core, lists, files, media, nav,
  overlays) plus lib/theme/design_tokens.dart for the color/type/space/
  radius/motion tokens.
- Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and
  toolbar), every tab (Files/Offline, Photos, Favorites, Recent,
  Activity, Trash, Shares), Settings, the lock and login screens, the
  media viewer, search, the details/share sheets, and the share-upload
  and move/copy destination pickers.
- Added a design canvas (linked from DESIGN_SYSTEM.md) covering the
  screens the spec didn't already describe, with matching Android and
  iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4.
- Removed now-dead legacy widgets (media_grid_tile, swipeable_item,
  sync_status_badge, selectable_thumbnail) and updated
  architecture.md/styling.md/standards.md to describe the new
  structure and component/testing conventions.
- Added widget tests for the noo/ component kit.

This is a UI-only rework: no provider/model/service behavior changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 01:51:58 -04:00
co-authored by Claude Opus 5.5
parent 51682a901d
commit 56784ef212
125 changed files with 15263 additions and 6804 deletions
+48
View File
@@ -0,0 +1,48 @@
# Demo server for Play Store screenshots
`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway
Nextcloud that only contains fake data**, so nothing real can appear in them.
This folder is that server:
- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing
persisted; `docker compose down -v` wipes it).
- `seed.py` - creates the users and fills them with invented content: landscape
"photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites,
shares (to a second user and a public link), trash, a filler file for the
storage quota, and edits by a second user so the Activity feed has more than
one author. It ends by printing `APP_PASSWORD=...` - an app password for the
demo user, so the app can start logged in without the browser login step.
## Running it
Most of the time just run the whole pipeline from the repo root:
```bash
bash tool/screenshots.sh
```
(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter**
and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed
into `tool/demo_server/.venv` automatically if your Python lacks them. The
script installs the emulator and a system image through `sdkmanager` on first
use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`,
`ANDROID_SERIAL`, ...) are documented at the top of the script.
To poke at the server yourself:
```bash
docker compose -f tool/demo_server/docker-compose.yml up -d
python3 tool/demo_server/seed.py # prints APP_PASSWORD=...
# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password)
docker compose -f tool/demo_server/docker-compose.yml down -v
```
`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated
files to `DIR` without any server - handy for previewing the fake content.
## Before you upload
Look at every image. The known places where real data could still show up:
the server address and username (sync header panel, Settings > Accounts), the
avatar initial, notification icons in the status bar, and anything left over
from a previously used emulator profile (the script always wipes its own).
+16
View File
@@ -0,0 +1,16 @@
# A throwaway Nextcloud that only ever holds fake data - the backend the Play
# Store screenshots are taken against (see tool/screenshots.sh). Nothing is
# persisted: `docker compose down -v` wipes it completely.
services:
nextcloud:
image: ${NEXTCLOUD_IMAGE:-nextcloud:apache}
ports:
- "${DEMO_PORT:-80}:80"
environment:
# SQLite keeps this to a single container.
SQLITE_DATABASE: nextcloud
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password
# `localhost` is how the Android emulator reaches this server (via
# `adb reverse`); DEMO_HOST is the optional friendlier hostname.
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}"
+2
View File
@@ -0,0 +1,2 @@
requests>=2.31
Pillow>=10.0
+348
View File
@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots.
Everything here is invented or procedurally generated (landscape "photos"
drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real -
and nothing with a licence or a face in it - can end up in a screenshot.
python3 seed.py seed http://localhost (DEMO_BASE_URL)
python3 seed.py --generate-only D write the fake files under D, no server
On success the LAST line printed to stdout is `APP_PASSWORD=<token>` - an app
password for the demo user, which tool/screenshots.sh hands to the app so it
can start logged in without going through the browser login flow. All
progress output goes to stderr.
"""
import argparse
import io
import math
import os
import random
import shutil
import subprocess
import sys
import tempfile
import time
from urllib.parse import quote
from PIL import Image, ImageDraw, ImageFilter
BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/")
ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password"))
FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300"))
# The account the screenshots are taken as, and a second user to share with.
USERS = {
"Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"),
"Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"),
}
def log(message):
print(message, file=sys.stderr, flush=True)
# --------------------------------------------------------------- content ---
# (sky top, sky bottom, sun, far hills, mid hills, near hills)
PALETTES = [
((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)),
((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)),
((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)),
((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)),
((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)),
((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)),
((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)),
((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)),
]
def _mix(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def landscape(seed, size=(1600, 1067)):
"""A soft, layered-hills landscape - reads as a photo at thumbnail size."""
rnd = random.Random(seed)
top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)]
w, h = size
img = Image.new("RGB", size)
draw = ImageDraw.Draw(img)
for y in range(h):
draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75))))
sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7)
draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun)
for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)):
phase = [rnd.uniform(0, math.tau) for _ in range(3)]
freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)]
weight = (1.0, 0.5, 0.2)
pts = [(0, h)]
for x in range(0, w + 8, 8):
v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase))
pts.append((x, int(h * base + h * amp * v / 1.7)))
pts.append((w, h))
draw.polygon(pts, fill=color)
img = img.filter(ImageFilter.GaussianBlur(0.8))
out = io.BytesIO()
img.save(out, "JPEG", quality=86)
return out.getvalue()
def pdf(title, lines):
"""A tiny but valid one-page PDF."""
def esc(s):
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title)
for i, line in enumerate(lines):
body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line))
stream = body.encode("latin-1", "replace")
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
b"/Resources << /Font << /F1 5 0 R >> >> >>",
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
out = bytearray(b"%PDF-1.4\n")
offsets = []
for i, obj in enumerate(objs, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n" % i + obj + b"\nendobj\n"
xref = len(out)
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for off in offsets:
out += b"%010d 00000 n \n" % off
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
return bytes(out)
def text(body):
return body.strip().encode("utf-8") + b"\n"
def video():
"""A short test-pattern clip if ffmpeg is around, else None (skipped)."""
if not shutil.which("ffmpeg"):
return None
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "clip.mp4")
result = subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24",
"-pix_fmt", "yuv420p", path],
capture_output=True,
)
if result.returncode != 0:
return None
with open(path, "rb") as f:
return f.read()
def build_content():
"""(path, bytes) for everything the demo user owns, plus what to favorite/delete."""
files = []
def add(path, data):
if data is not None:
files.append((path, data))
add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go."))
add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter"))
add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"]))
add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"]))
add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite"))
add("/Documents/Budget 2026.xlsx", os.urandom(24_000))
add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"]))
add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"]))
trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"]
for i, name in enumerate(trips):
add("/Trips/%s.jpg" % name, landscape(i + 1))
add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"]))
for i in range(8):
add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i))
add("/Photos/Summer 2026/Clip - hike.mp4", video())
add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper."))
add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt."))
add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans"))
add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"]))
add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}'))
add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets."))
favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"]
# Created, then deleted - they end up in the Trash tab.
trashed = [
("/Old draft.txt", text("Superseded draft.")),
("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))),
("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])),
]
return files, favorites, trashed
# ---------------------------------------------------------------- server ---
class ZeroStream:
"""`size` zero bytes, streamed - a big filler file without holding it in memory."""
def __init__(self, size):
self.size, self.pos = size, 0
def __len__(self):
return self.size
def read(self, n=-1):
n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos)
self.pos += n
return b"\0" * n
class Server:
def __init__(self):
import requests # imported lazily so --generate-only needs no network libs
self.requests = requests
def _call(self, method, url, auth, **kw):
headers = kw.pop("headers", {})
headers.setdefault("OCS-APIRequest", "true")
return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw)
def wait_ready(self):
log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...")
deadline = time.time() + 600
while time.time() < deadline:
try:
r = self.requests.get(BASE + "/status.php", timeout=5)
if r.ok and r.json().get("installed") and not r.json().get("maintenance"):
return
except Exception:
pass
time.sleep(3)
sys.exit("Nextcloud did not become ready in time")
def ocs(self, method, path, auth, **kw):
r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw)
try:
return r.json()["ocs"]
except Exception:
return {"meta": {"statuscode": r.status_code}, "data": {}}
def create_user(self, user, password, display, email, quota):
res = self.ocs(
"POST", "/ocs/v1.php/cloud/users", ADMIN,
data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota},
)
code = res["meta"]["statuscode"]
if code not in (100, 200, 102): # 102 = already exists
sys.exit("Could not create user %s: %s" % (user, res))
def dav(self, method, user, path, **kw):
auth = (user, USERS[user][0])
url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path))
return self._call(method, url, auth, **kw)
def mkdirs(self, user, path):
built = ""
for part in [p for p in path.split("/") if p]:
built += "/" + part
self.dav("MKCOL", user, built)
def put(self, user, path, data):
parent = path.rsplit("/", 1)[0]
if parent:
self.mkdirs(user, parent)
r = self.dav("PUT", user, path, data=data)
if r.status_code not in (200, 201, 204):
sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200]))
def favorite(self, user, path):
body = (
'<?xml version="1.0"?><d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
"<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set></d:propertyupdate>"
)
self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"})
def delete(self, user, path):
self.dav("DELETE", user, path)
def share(self, user, path, share_type, share_with=None, permissions=31):
data = {"path": path, "shareType": share_type, "permissions": permissions}
if share_with:
data["shareWith"] = share_with
auth = (user, USERS[user][0])
self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data)
def app_password(self, user):
auth = (user, USERS[user][0])
res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth)
token = (res.get("data") or {}).get("apppassword")
if not token:
sys.exit("Could not get an app password for %s: %s" % (user, res))
return token
def seed_server():
server = Server()
server.wait_ready()
for user, (password, display, email, quota) in USERS.items():
log("Creating user %s" % user)
server.create_user(user, password, display, email, quota)
files, favorites, trashed = build_content()
log("Uploading %d files as Alex" % len(files))
for path, data in files:
server.put("Alex", path, data)
if FILLER_MB > 0:
log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB)
server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024))
for path in favorites:
server.favorite("Alex", path)
log("Creating shares")
server.share("Alex", "/Trips", 0, "Sam", permissions=31)
server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1)
server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch."))
server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30."))
server.share("Sam", "/Team notes", 0, "Alex", permissions=31)
# Sam edits inside the folder Alex shared - gives the Activity feed a second author.
server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!"))
log("Filling the trash")
for path, data in trashed:
server.put("Alex", path, data)
for path, _ in trashed:
server.delete("Alex", path)
server.delete("Alex", "/Receipts (old)")
token = server.app_password("Alex")
print("APP_PASSWORD=" + token, flush=True)
def generate_only(target):
files, favorites, trashed = build_content()
for path, data in files + trashed:
dest = os.path.join(target, "Alex" + path)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(data)
log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites)))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server")
args = parser.parse_args()
if args.generate_only:
generate_only(args.generate_only)
else:
seed_server()