- Add SavedAccount/AccountStore and rework ServerProvider around a list of saved accounts: switch/remove/add, swipe-to-cycle on the avatar button, per-account browsing prefs (sort/view/filters/cache), and a session-generation guard so a stale fetch from an abandoned account can't land in the newly active one - Migrate existing single-account installs into the new schema automatically so upgrading users stay logged in - Settings gains an Accounts section; LoginView gains an "add account" mode pushed on top of the main shell instead of replacing it - Give every Scrollbar in the app a thicker, draggable, Android-style thumb via a shared ScrollbarTheme instead of the default hairline - Splash screen: use the monochrome app icon (tinted per theme) and a smaller spinner - Update CLAUDE.md and .claude/context/*.md to reflect the accumulated multi-account, sharing, and UI changes across this session, and add a standing instruction to keep these docs current going forward Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
7.7 KiB
Server integration
Auth: Login Flow v2
The app never collects a Nextcloud password directly. It implements
Login Flow v2
via LoginFlowService:
LoginFlowService.initiate(serverUrl)POSTs to{server}/index.php/login/v2, gets back a browser login URL + a poll endpoint/token.- The app opens the login URL in a Chrome Custom Tab (
url_launcher,LaunchMode.inAppBrowserView— real Chrome, so saved passwords/autofill work, unlike Flutter's own embedded web view); the user authenticates and authorizes there. There's no way to close the tab automatically on success (Login Flow v2 never redirects back into the app, and a Custom Tab belongs to Chrome's own task) — the user switches back manually. ServerProviderpollsLoginFlowService.poll(pollEndpoint, token)every 2 seconds (Timer.periodic, see_pollTimer/_pollTimeoutTimerinserver_provider.dart) until it gets a 200 withserver/loginName/appPassword, a non-404 error, or a 10-minute timeout. A single dropped connection mid-poll (http.ClientException) is swallowed and retried on the next tick rather than aborting the whole flow.- The returned app password (scoped, revocable) is what gets stored and used for every subsequent request — real user passwords are never in memory or on disk.
LoginFlowStatus (idle → initiating → awaitingBrowser → error)
drives LoginView's UI; see LoginFlowService's doc comment for the full
flow rationale before changing it.
startLoginFlow(serverUrl, {addAccount = false}) is reused verbatim for
both the first/only login and "add another account" (pushed from Settings
while already logged into a different account, LoginView(isAddingAccount: true)) — addAccount only flags isAddAccountFlow for the UI (so the
pushed screen knows to auto-pop on success and cancel the flow on
back-swipe); the persistence path on success is identical either way, see
below.
Talking to the server
NextcloudService is the
client for an authenticated session — constructed with serverUrl +
username + the app password, one instance per login (held as
ServerProvider.service, recreated on every login/switch/logout). It's
effectively stateless per-instance (three final fields, headers rebuilt per
request), which is what makes it trivial to have one saved per account
rather than needing a rewrite for multi-account support.
- Files: WebDAV (
PROPFIND/MKCOL/DELETE/MOVE/PUTetc. against/remote.php/dav/files/{username}/...) via rawhttp/diocalls with a hand-rolled XML request body andpackage:xmlfor parsing responses — there is no WebDAV client dependency._parseDavDate/_davPathin this file exist because WebDAV responses use RFC 1123 dates and either bare paths or full URLs forhref; reuse them rather than re-deriving. - Everything else (shares, activity, trash, favorites, quota, user info,
file versions) goes through Nextcloud's OCS APIs (
/ocs/v2.php/...), JSON in, with theOCS-APIRequest: trueheader required on every OCS call. - Auth header is HTTP Basic (
username:appPassword, base64), built in_headers/exposed asauthHeadersfor widgets that need to hit URLs directly (e.g.Image.network(url, headers: service.authHeaders)for thumbnails/previews). - Downloads stream through
Dio(downloadToFile) for progress callbacks; small in-app previews (text/PDF) usefetchBytesviapackage:http. - Uploads (
uploadFileFromPath(folderPath, fileName, localFilePath, {onProgress})) stream the local file viaDio().put()with an explicitContent-LengthandonSendProgress, mirroring the download path. TheServerProviderwrapper always uploads into_currentFolderPath— the share-to-upload flow (ShareUploadView) gets a caller-chosen destination by navigating there first (navigateToAbsoluteFolder), then uploading. - Receiving a shared file from another app:
receive_sharing_intent(AndroidACTION_SEND/ACTION_SEND_MULTIPLE,android:launchModesingleTaskin the manifest so a second share while running hitsonNewIntentinstead of spawning a new instance).MainShellViewlistens viagetInitialMedia()/getMediaStream()and pushesShareUploadView, which reuses the sameuploadFileFromPathpath after the user picks a destination folder.
Multi-account storage & session persistence
AccountStore owns everything
account-identity-related; ServerProvider owns everything about which
account is currently live (see architecture.md).
- Per-account secrets: one
flutter_secure_storagekey per account,nc_app_password_<accountId>— nevershared_preferences.accountIdis deterministic (SavedAccount.makeId(serverUrl, username), a slug of both), so re-adding the same account refreshes its password instead of creating a duplicate. - Account identity list (non-secret: id/serverUrl/username) and
which one is active live in
shared_preferencesasaccounts_list(JSON array) andactive_account_id. - Global UI prefs (theme, dynamic color, AMOLED, bottom-bar
opacity/blur, tap-to-scroll-top, seek bar style, tab order/hidden/default,
swipe actions) stay flat, un-namespaced
shared_preferenceskeys — same as before multi-account, untouched by switching. - Per-account browsing prefs (grid/list view, favorites-only ×2,
storage scope, show-hidden ×2, Photos sort field/ascending, Files'
per-folder sort map, cache policy/interval — the full list is
AccountStore.perAccountPrefKeys) are namespacedacct_<accountId>_<key>and reloaded on every switch viaServerProvider._applyAccountPrefs. - Legacy migration:
AccountStore.migrateLegacyIfNeededruns once ever (guarded by theaccount_migration_v1_doneflag), turning a pre-multi- account install's 3 flat secure-storage keys + flat browsing prefs into the first saved (and active) account, so upgrading users are never logged out. Never assume the legacy keys are gone — always check the migration flag rather than the keys' absence. - On startup,
ServerProvider._init()awaits the migration, loads the account list + active id, then_restoreSession()looks up the active account's password and calls_applyCredentialsForAccount(the renamed, generation-guarded, account-aware version of what used to be_applyCredentials) to rebuild the session without re-hitting the login flow. - Switching accounts (
switchAccount/cycleToNextAccount/cycleToPreviousAccount/removeAccount's fallback, plus landing on a freshly-added account) all funnel through the single_activateAccountengine: bump_sessionGeneration, cancel any pending login flow, clear every content field without ever settingisLoggedInfalse (that's the detail that keepsmain.dart's root routing from bouncing throughLoginViewmid-switch), reload the target account's prefs, then verify its credentials and refetch everything. This is a full teardown-and-reload every time — there is deliberately no simultaneous multi-account state or background sync; only one account's content is ever live. logout()is justremoveAccount(activeAccountId)— with other accounts saved it falls back to one of them instead of ending the session;isLoggedInonly ever becomesfalsewhen the last account is removed.
isRestoringSession still gates the splash screen until the above resolves
— see standards.md for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally.