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>
8.4 KiB
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 typicallyStatefulWidgetwhen they own controllers/local UI state (e.g.LoginView's form key + text controller); presentation is frequently split into small privateStatelessWidgets in the same file (_ServerForm,_WaitingForBrowserinlogin_view.dart) rather than inlined in one largebuild. 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 towidgets/only once something is reused across files. - Read controller state with
context.watch<XController>()inbuild, andcontext.read<XController>()for one-off calls from callbacks (matchesLoginView._handleContinue). Pull in only the specific controller(s) a widget actually needs (e.g.FilesController+SyncStatusControllerfor a Files tile), not a catch-all — seearchitecture.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: capturefinal gen = session.sessionGeneration;at entry, and checkif (gen != session.sessionGeneration) return;immediately after eachawaitbefore touching any field or callingnotifyListeners(). 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/_onAccountActivatedlisteners (registered viaSessionController.addAccountClearedListener/addAccountActivatedListenerin 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 mockMethodChannel('plugins.it_nomads.com/flutter_secure_storage')handler, andconnectivity_plus'sMethodChannel('dev.fluttercommunity.plus/ connectivity')('check'→ a result list, e.g.['wifi']) plus itsEventChannel('dev.fluttercommunity.plus/connectivity_status')(aMockStreamHandler.inlinewith a no-oponListenis enough) — and disable Google Fonts network fetching (GoogleFonts.config.allowRuntimeFetching = false) insetUpAll. 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 makespumpAndSettle()hang until its own timeout instead of failing fast. Seetest/widget_test.dartfor the reference setup. -
Design-system components (
lib/widgets/noo/) are tested intest/widgets/noo/, one file per component folder. Use the helpers innoo_test_utils.dart:setUpNooTests()turns off font fetching.testNooWidgets(...)runs a body once per theme, light and dark, built through the realAppTheme, and hands it theNooColors.pumpNoo(...)mounts the widget.
Build themes inside the test body, not at file level, because
AppThemetouches Google Fonts before the test binding exists.pumpNoocenters 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
AccountStorebefore callingmain(), 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.iconinsideFloatingBottomNavBar), tooltips, theValueKey('files')/ValueKey('offline')on the twoFilesViews, and the fake content's names (defined intool/demo_server/seed.py) - keep those in sync if you rename either side. flutter testonly runstest/, so this never runs as part of the normal suite;integration_testis a dev-only dependency.- Always review the images before uploading - see
tool/demo_server/README.mdfor 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:
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.