- Release builds now sign with a dedicated keystore (local android/key.properties or CI secrets, both gitignored) instead of each machine's own debug key, so a downloaded release APK can actually update a previous install instead of failing with "App not installed" (mismatched signature). - The Files tab's "+" -> "Upload File" now goes through the same ShareUploadView destination picker and background foreground-service upload as receiving a file via Android's "Share to..." sheet, instead of a separate blocking in-app-only upload path. Removed the now-unused ServerProvider/NextcloudService.uploadFileFromPath. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.2 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 provider state with
context.watch<ServerProvider>()inbuild, andcontext.read<ServerProvider>()for one-off calls from callbacks (matchesLoginView._handleContinue).
ServerProvider conventions
- Any method that fetches data and writes it into a shared field
(
refreshData,fetchAllMedia,fetchTrash,fetchShares,fetchRecent,_applyCredentialsForAccount) must guard against a stale write from an account the user has since switched away from: capturefinal gen = _sessionGeneration;at entry, and checkif (gen != _sessionGeneration) return;immediately after eachawaitbefore touching any field or callingnotifyListeners(). Follow this pattern for any new fetch method added to the provider. - New persisted state on
ServerProvidermust be classified global vs. per-account (seearchitecture.md/server.md) up front — global state 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_applyAccountPrefs(both the "reset to default when no account" and the "load for this account" branches) 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({})and a mockMethodChannel('plugins.it_nomads.com/flutter_secure_storage')handler — and disable Google Fonts network fetching (GoogleFonts.config.allowRuntimeFetching = false) insetUpAll. Without these,ServerProvider'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. - Run with
flutter test.
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 by .gitea/workflows/build.yml from repo secrets), 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.