Files
noo/.claude/context/standards.md
T
ayushyaandClaude Opus 5.5 56784ef212 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>
2026-09-26 01:51:58 -04:00

160 lines
8.4 KiB
Markdown

# Code standards
## Linting
`analysis_options.yaml` includes `package:flutter_lints/flutter.yaml` with
no rules added/relaxed. Run `flutter analyze` before considering a change
done; it should stay clean.
## Comments
Comments are used sparingly and only for non-obvious *why* — a hidden
constraint, a workaround, or the rationale for overriding a framework
default. See `AppTheme._pageTransitionsTheme`/`_sliderTheme`,
`NextcloudService._parseDavDate`/`_davPath`, and `LoginFlowService`'s class
doc comment for the house style: one short doc comment on the
class/function explaining *why* it exists, not what each line does. Don't
add comments that restate the code or describe what a well-named
class/method already makes obvious.
## Widget structure
- Screens (`views/`) are typically `StatefulWidget` when they own
controllers/local UI state (e.g. `LoginView`'s form key + text
controller); presentation is frequently split into small private
`StatelessWidget`s in the same file (`_ServerForm`, `_WaitingForBrowser`
in `login_view.dart`) rather than inlined in one large `build`. Follow
this split for any view complex enough to have more than one visual
"mode".
- Private helpers/widgets are prefixed with `_` and live in the same file as
their one caller; promote to `widgets/` only once something is reused
across files.
- Read controller state with `context.watch<XController>()` in `build`, and
`context.read<XController>()` for one-off calls from callbacks (matches
`LoginView._handleContinue`). Pull in only the specific controller(s) a
widget actually needs (e.g. `FilesController` + `SyncStatusController`
for a Files tile), not a catch-all — see `architecture.md`'s "State
management" section for the full controller split and what each one owns.
## Controller conventions
- Any method that fetches data and writes it into a shared field
(`FilesController.refreshData`, `PhotosController.fetchAllMedia`,
`TrashController.fetchAll`, `SharesController.fetchAll`,
`RecentController.fetchAll`, `SessionController._applyCredentialsForAccount`)
must guard against a stale write from an account the user has since
switched away from: capture `final gen = session.sessionGeneration;` at
entry, and check `if (gen != session.sessionGeneration) return;`
immediately after each `await` before touching any field or calling
`notifyListeners()`. Follow this pattern for any new fetch method added
to any controller.
- New persisted state must be classified global vs. per-account (see
`architecture.md`/`server.md`) up front and live on whichever controller
owns that domain — global state (`SettingsController`) uses a plain
`_prefsFuture.then((p) => p.setX(key, value))`; per-account state goes
through `_persistAccountPref(key, (p, namespacedKey) =>
p.setX(namespacedKey, value))` and must also be handled in that
controller's own `_onAccountCleared`/`_onAccountActivated` listeners
(registered via `SessionController.addAccountClearedListener`/
`addAccountActivatedListener` in the controller's constructor) so it's
correct immediately after a switch, not just at startup.
## Testing
- Widget tests must mock platform channels that the app touches on startup
— `SharedPreferences.setMockInitialValues({})`, a mock
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler,
and `connectivity_plus`'s `MethodChannel('dev.fluttercommunity.plus/
connectivity')` (`'check'` → a result list, e.g. `['wifi']`) plus its
`EventChannel('dev.fluttercommunity.plus/connectivity_status')` (a
`MockStreamHandler.inline` with a no-op `onListen` is enough) — and
disable Google Fonts network fetching
(`GoogleFonts.config.allowRuntimeFetching = false`) in `setUpAll`. Without
these, `SessionController`'s session restore never resolves in the test
sandbox (no plugin implementation is registered, so the read future just
never completes) and the app stays on `_SplashView`'s indeterminate
spinner, which makes `pumpAndSettle()` hang until its own timeout instead
of failing fast. See `test/widget_test.dart` for the reference setup.
- Design-system components (`lib/widgets/noo/`) are tested in
`test/widgets/noo/`, one file per component folder. Use the helpers in
`noo_test_utils.dart`:
- `setUpNooTests()` turns off font fetching.
- `testNooWidgets(...)` runs a body once per theme, light and dark, built
through the real `AppTheme`, and hands it the `NooColors`.
- `pumpNoo(...)` mounts the widget.
Build themes inside the test body, not at file level, because `AppTheme`
touches Google Fonts before the test binding exists. `pumpNoo` centers
the child, which loosens its constraints. To catch a widget stretching to
fill its parent, put it in a bounded box (for example, `SizedBox` +
`Align`).
- Run with `flutter test`.
## Play Store screenshots
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
listing screenshots **without any real data**: it runs the real, unmodified app
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
it with `integration_test/store_screenshots_test.dart` (host-side driver:
`test_driver/integration_test.dart`, which saves `adb screencap` images).
Output lands in `store_listing/screenshots/` (gitignored) after
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
limits (each side 320-3840 px, long side at most 2x the short side - a stock
1080x2400 phone screen is 2.22:1 and would be rejected).
- The test logs in by writing the demo user's account/app password into
storage via `AccountStore` before calling `main()`, so it skips the browser
login flow; it also fixes the theme (light, non-dynamic colour), the visible
tabs and the first-run notification prompt so shots are reproducible.
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
`FilesView`s, and the fake content's names (defined in
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
- `flutter test` only runs `test/`, so this never runs as part of the normal
suite; `integration_test` is a dev-only dependency.
- Always review the images before uploading - see
`tool/demo_server/README.md` for the known places real data could appear.
## Local install/deploy
Never use `flutter install` to push a build to a test device — it always
does a full **uninstall-then-install** (prints "Uninstalling old
version..."), and Android deletes all app data (SharedPreferences, secure
storage — every saved account/preference) on uninstall. This wipes the app
clean on every single deploy, which looks like an account/settings-loss bug
but is actually just the install method.
Instead, build then install with `adb`'s replace flag, which updates the
APK in place and preserves app data:
```bash
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
This only preserves data if the new APK's signature matches what's already
on the device. `android/app/build.gradle.kts` picks a release signing key
in this order: a local `android/key.properties` (gitignored — points at a
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
the `.aab` Play Console wants), then falls back to the debug key if neither
is configured. As long as the same dedicated release keystore backs both
`key.properties` locally and the Gitea secrets, local release builds and
CI-built release APKs share one signature, so `adb install -r` works
cleanly either way. A local checkout with no `key.properties` set up falls
back to the (per-machine, ungitted) debug key, which won't match a
CI-signed APK — installing one over the other still forces a full
uninstall, since there's no way around Android's signature check from the
tooling side.
## Dependencies
Networking is deliberately split: `package:http` for simple JSON/XML
request-response calls, `package:dio` only where streaming/progress is
needed (downloads). Don't introduce a third HTTP client — extend the
existing split instead.