Add multi-account support and update project docs
- 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>
This commit is contained in:
@@ -4,12 +4,18 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
main.dart # app root, theme wiring, top-level navigation switch
|
main.dart # app root, theme wiring, top-level navigation switch,
|
||||||
models/ # plain data classes (NextcloudItem, NextcloudShare, ...)
|
# MainShellView (bottom-nav shell), share-intent listener
|
||||||
|
models/ # plain data classes (NextcloudItem, NextcloudShare,
|
||||||
|
# SavedAccount, AppTab, ...)
|
||||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||||
services/ # network/IO: NextcloudService, LoginFlowService
|
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||||
|
# AccountStore
|
||||||
theme/ # AppTheme (Material 3 ThemeData)
|
theme/ # AppTheme (Material 3 ThemeData)
|
||||||
views/ # one screen each (FilesView, PhotosView, ...)
|
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||||
|
# SharesView, RecentView, ActivityView, SearchView,
|
||||||
|
# AccountView, LoginView, FileViewerScreen,
|
||||||
|
# ShareUploadView)
|
||||||
widgets/ # reusable pieces shared across views
|
widgets/ # reusable pieces shared across views
|
||||||
details/ # the file-details bottom sheet and its tabs
|
details/ # the file-details bottom sheet and its tabs
|
||||||
```
|
```
|
||||||
@@ -22,38 +28,83 @@ read via `context.watch`/`context.read`.
|
|||||||
|
|
||||||
## State management
|
## State management
|
||||||
|
|
||||||
There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart).
|
There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart)
|
||||||
It is created once in `main()` and provided at the root with `provider`'s
|
(~1500 lines). It is created once in `main()` and provided at the root with
|
||||||
`ChangeNotifierProvider`. It owns:
|
`provider`'s `ChangeNotifierProvider`. It owns:
|
||||||
|
|
||||||
- auth/session state (`isLoggedIn`, `isRestoringSession`, login-flow status)
|
- **Multi-account state**: the list of saved accounts (`accounts`), which one
|
||||||
|
is active (`activeAccountId`/`activeAccount`), and a `_sessionGeneration`
|
||||||
|
counter incremented on every account switch so an in-flight fetch from the
|
||||||
|
account just left can recognize it's stale and discard its result instead
|
||||||
|
of writing into the newly-active account's state — every method that
|
||||||
|
writes fetched data into a shared field (`refreshData`, `fetchAllMedia`,
|
||||||
|
`fetchTrash`, `fetchShares`, `fetchRecent`,
|
||||||
|
`_applyCredentialsForAccount`) captures the generation at entry and checks
|
||||||
|
it before each write. See `server.md` for the full account-switch/storage
|
||||||
|
story.
|
||||||
|
- auth/session state for whichever account is active (`isLoggedIn`,
|
||||||
|
`isRestoringSession`, login-flow status)
|
||||||
- the active `NextcloudService` instance (null until logged in)
|
- the active `NextcloudService` instance (null until logged in)
|
||||||
- all fetched data (`items`, `quota`, `activities`)
|
- all fetched data, scoped to the active account (`items`, `quota`,
|
||||||
- navigation-within-files state (`currentFolderPath`, `pathStack`)
|
`activities`, `photoItems`, `trashItems`, `shares`, `recentItems`) — all
|
||||||
- UI settings that persist across launches (theme mode, seed color, dynamic
|
torn down and refetched fresh on every account switch (no simultaneous
|
||||||
color toggle, bottom-bar opacity/blur, sort/filter/view-mode prefs)
|
multi-account state; only one account's content is ever live in memory)
|
||||||
|
- navigation-within-files state (`currentFolderPath`, `pathStack`), plus an
|
||||||
|
in-memory `_directoryCache` keyed by folder path (see `CachePolicy`)
|
||||||
|
- UI settings that persist across launches — split into **global** (theme
|
||||||
|
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||||
|
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||||
|
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
|
||||||
|
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
|
||||||
|
`server.md` for exactly which is which and why
|
||||||
|
|
||||||
New app-wide state belongs on `ServerProvider` as a private field + getter +
|
New **global** state belongs on `ServerProvider` as a private field + getter
|
||||||
a method that mutates it and calls `notifyListeners()`. Screen-local state
|
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
||||||
(e.g. a `TextEditingController`, an expanded/collapsed flag) stays in that
|
`_prefsFuture`. New **per-account** state follows the same shape but
|
||||||
view's own `State` class — see `standards.md` for the split.
|
persists through `_persistAccountPref` (namespaces the pref key under
|
||||||
|
`acct_<activeAccountId>_...` via `AccountStore.accountPrefKey`) and must be
|
||||||
|
reset/reloaded in `_applyAccountPrefs` so it's correct after a switch.
|
||||||
|
Screen-local state (e.g. a `TextEditingController`, an expanded/collapsed
|
||||||
|
flag) stays in that view's own `State` class — see `standards.md` for the
|
||||||
|
split.
|
||||||
|
|
||||||
There's no separate repository/data layer: views call `ServerProvider`
|
There's no separate repository/data layer: views call `ServerProvider`
|
||||||
methods directly, which call `NextcloudService`/`LoginFlowService`.
|
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||||
|
`AccountStore`.
|
||||||
|
|
||||||
## Navigation / screen flow
|
## Navigation / screen flow
|
||||||
|
|
||||||
`main.dart`'s `NextcloudApp` picks the app's `home` screen from provider
|
`main.dart`'s `NextcloudApp` picks the app's `home` screen from provider
|
||||||
state, no named routes:
|
state, no named routes:
|
||||||
|
|
||||||
- `provider.isRestoringSession` → `_SplashView` (spinner while
|
- `provider.isRestoringSession` → `_SplashView` (monochrome app icon,
|
||||||
`flutter_secure_storage`/`shared_preferences` are read on startup)
|
tinted via `ColorFiltered` to the theme's `onSurface` so it works in both
|
||||||
|
light/dark, plus a small spinner, while `flutter_secure_storage`/
|
||||||
|
`shared_preferences` are read on startup)
|
||||||
- else `!provider.isLoggedIn` → `LoginView` (server-address entry + Login
|
- else `!provider.isLoggedIn` → `LoginView` (server-address entry + Login
|
||||||
Flow v2)
|
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
||||||
|
account is removed; switching between multiple saved accounts never
|
||||||
|
routes through this screen (see `server.md`)
|
||||||
- else `MainShellView`
|
- else `MainShellView`
|
||||||
|
|
||||||
`MainShellView` is a bottom-nav `IndexedStack` with three persistent tabs —
|
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
||||||
Files, Photos, Activity — each keeping its own `ScrollController` so state
|
Photos, Activity, Trash, Shares, Recent — user-configurable (order,
|
||||||
(scroll position, `IndexedStack`'s built-but-hidden trees) survives tab
|
visibility up to `maxVisibleTabs`, default tab) via `AppTab`/`ServerProvider`
|
||||||
switches. `SearchView` and the file-details sheet are pushed on top via
|
and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`).
|
||||||
`Navigator`/`showModalBottomSheet` rather than being tabs.
|
Each tab keeps its own `ScrollController` (survives tab switches via
|
||||||
|
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
||||||
|
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
||||||
|
owns the app's share-intent listener (`receive_sharing_intent`): both
|
||||||
|
`getInitialMedia()` (cold start via another app's "Share to...") and
|
||||||
|
`getMediaStream()` (already running) push `ShareUploadView`.
|
||||||
|
|
||||||
|
Five of the six tabs (all but Files) plus each tab's own controls share
|
||||||
|
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
||||||
|
`CustomScrollView` with a pull-down "sync status" header (Google
|
||||||
|
Photos-style), a `Scrollbar`, and a classic Material refresh spinner shown
|
||||||
|
during a pull-triggered sync. `SearchView`, `AccountView` (Settings), the
|
||||||
|
file-details sheet, the share sheet, and `ShareUploadView` (the
|
||||||
|
share-to-upload destination picker) are pushed on top via
|
||||||
|
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than
|
||||||
|
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on
|
||||||
|
tap and cycles between saved accounts on a vertical swipe.
|
||||||
|
|||||||
+88
-21
@@ -9,12 +9,18 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
|||||||
1. `LoginFlowService.initiate(serverUrl)` POSTs to
|
1. `LoginFlowService.initiate(serverUrl)` POSTs to
|
||||||
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
|
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
|
||||||
endpoint/token.
|
endpoint/token.
|
||||||
2. The app opens the login URL in the system browser (`url_launcher`); the
|
2. The app opens the login URL in a Chrome Custom Tab (`url_launcher`,
|
||||||
user authenticates and authorizes there.
|
`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.
|
||||||
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
||||||
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
||||||
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
||||||
`appPassword`, a non-404 error, or a 10-minute timeout.
|
`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.
|
||||||
4. The returned **app password** (scoped, revocable) is what gets stored and
|
4. The returned **app password** (scoped, revocable) is what gets stored and
|
||||||
used for every subsequent request — real user passwords are never in
|
used for every subsequent request — real user passwords are never in
|
||||||
memory or on disk.
|
memory or on disk.
|
||||||
@@ -23,41 +29,102 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
|||||||
drives `LoginView`'s UI; see `LoginFlowService`'s doc comment for the full
|
drives `LoginView`'s UI; see `LoginFlowService`'s doc comment for the full
|
||||||
flow rationale before changing it.
|
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
|
## Talking to the server
|
||||||
|
|
||||||
[`NextcloudService`](../../lib/services/nextcloud_service.dart) is the
|
[`NextcloudService`](../../lib/services/nextcloud_service.dart) is the
|
||||||
client for an authenticated session — constructed with `serverUrl` +
|
client for an authenticated session — constructed with `serverUrl` +
|
||||||
`username` + the app password, one instance per login (held as
|
`username` + the app password, one instance per login (held as
|
||||||
`ServerProvider.service`, recreated on login/logout).
|
`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` etc. against
|
- **Files**: WebDAV (`PROPFIND`/`MKCOL`/`DELETE`/`MOVE`/`PUT` etc. against
|
||||||
`/remote.php/dav/files/{username}/...`) via raw `http`/`dio` calls with a
|
`/remote.php/dav/files/{username}/...`) via raw `http`/`dio` calls with a
|
||||||
hand-rolled XML request body and `package:xml` for parsing responses —
|
hand-rolled XML request body and `package:xml` for parsing responses —
|
||||||
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
|
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
|
||||||
file exist because WebDAV responses use RFC 1123 dates and either bare
|
file exist because WebDAV responses use RFC 1123 dates and either bare
|
||||||
paths or full URLs for `href`; reuse them rather than re-deriving.
|
paths or full URLs for `href`; reuse them rather than re-deriving.
|
||||||
- **Everything else** (shares, activity, trash, favorites, quota, user info)
|
- **Everything else** (shares, activity, trash, favorites, quota, user info,
|
||||||
goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON in, with the
|
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
|
||||||
`OCS-APIRequest: true` header required on every OCS call.
|
in, with the `OCS-APIRequest: true` header required on every OCS call.
|
||||||
- Auth header is HTTP Basic (`username:appPassword`, base64), built in
|
- Auth header is HTTP Basic (`username:appPassword`, base64), built in
|
||||||
`_headers`/exposed as `authHeaders` for widgets that need to hit URLs
|
`_headers`/exposed as `authHeaders` for widgets that need to hit URLs
|
||||||
directly (e.g. `Image.network(url, headers: service.authHeaders)` for
|
directly (e.g. `Image.network(url, headers: service.authHeaders)` for
|
||||||
thumbnails/previews).
|
thumbnails/previews).
|
||||||
- Downloads stream through `Dio` (`downloadToFile`) for progress callbacks;
|
- Downloads stream through `Dio` (`downloadToFile`) for progress callbacks;
|
||||||
small in-app previews (text/PDF) use `fetchBytes` via `package:http`.
|
small in-app previews (text/PDF) use `fetchBytes` via `package:http`.
|
||||||
|
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath,
|
||||||
|
{onProgress})`) stream the local file via `Dio().put()` with an explicit
|
||||||
|
`Content-Length` and `onSendProgress`, mirroring the download path. The
|
||||||
|
`ServerProvider` wrapper 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`
|
||||||
|
(Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `android:launchMode`
|
||||||
|
`singleTask` in the manifest so a second share while running hits
|
||||||
|
`onNewIntent` instead of spawning a new instance). `MainShellView` listens
|
||||||
|
via `getInitialMedia()`/`getMediaStream()` and pushes `ShareUploadView`,
|
||||||
|
which reuses the same `uploadFileFromPath` path after the user picks a
|
||||||
|
destination folder.
|
||||||
|
|
||||||
## Session persistence
|
## Multi-account storage & session persistence
|
||||||
|
|
||||||
- **Credentials** (`server`, `loginName`, `appPassword`) live in
|
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
||||||
`flutter_secure_storage` — OS keychain/keystore-backed, never
|
account-identity-related; `ServerProvider` owns everything about which
|
||||||
`shared_preferences`.
|
account is *currently* live (see `architecture.md`).
|
||||||
- **UI/app preferences** (theme mode, seed color, dynamic-color toggle,
|
|
||||||
bottom-bar opacity/blur, grid vs. list, sort field, hidden-files toggle,
|
- **Per-account secrets**: one `flutter_secure_storage` key per account,
|
||||||
etc.) live in `shared_preferences` — see the `_pref*` key constants at the
|
`nc_app_password_<accountId>` — never `shared_preferences`. `accountId` is
|
||||||
top of `server_provider.dart`.
|
deterministic (`SavedAccount.makeId(serverUrl, username)`, a slug of both),
|
||||||
- On startup, `ServerProvider._restoreSession()` reads the secure-storage
|
so re-adding the same account refreshes its password instead of creating a
|
||||||
keys and, if all three are present, rebuilds a `NextcloudService` without
|
duplicate.
|
||||||
re-hitting the login flow (`_applyCredentials(..., persist: false)`).
|
- **Account identity list** (non-secret: id/serverUrl/username) and
|
||||||
`isRestoringSession` gates the splash screen until this resolves — see
|
**which one is active** live in `shared_preferences` as `accounts_list`
|
||||||
`standards.md` for why widget tests must mock both storage channels
|
(JSON array) and `active_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_preferences` keys — 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 namespaced `acct_<accountId>_<key>`
|
||||||
|
and reloaded on every switch via `ServerProvider._applyAccountPrefs`.
|
||||||
|
- **Legacy migration**: `AccountStore.migrateLegacyIfNeeded` runs once ever
|
||||||
|
(guarded by the `account_migration_v1_done` flag), 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 `_activateAccount`
|
||||||
|
engine: bump `_sessionGeneration`, cancel any pending login flow, clear
|
||||||
|
every content field *without* ever setting `isLoggedIn` false (that's the
|
||||||
|
detail that keeps `main.dart`'s root routing from bouncing through
|
||||||
|
`LoginView` mid-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 just `removeAccount(activeAccountId)` — with other accounts
|
||||||
|
saved it falls back to one of them instead of ending the session;
|
||||||
|
`isLoggedIn` only ever becomes `false` when 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.
|
rather than relying on this async path throwing naturally.
|
||||||
|
|||||||
@@ -33,6 +33,25 @@ class/method already makes obvious.
|
|||||||
and `context.read<ServerProvider>()` for one-off calls from callbacks
|
and `context.read<ServerProvider>()` for one-off calls from callbacks
|
||||||
(matches `LoginView._handleContinue`).
|
(matches `LoginView._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: capture
|
||||||
|
`final gen = _sessionGeneration;` at entry, and check
|
||||||
|
`if (gen != _sessionGeneration) return;` immediately after each `await`
|
||||||
|
before touching any field or calling `notifyListeners()`. Follow this
|
||||||
|
pattern for any new fetch method added to the provider.
|
||||||
|
- New persisted state on `ServerProvider` must be classified global vs.
|
||||||
|
per-account (see `architecture.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
|
## Testing
|
||||||
|
|
||||||
- Widget tests must mock platform channels that the app touches on startup
|
- Widget tests must mock platform channels that the app touches on startup
|
||||||
|
|||||||
@@ -29,18 +29,49 @@ widgets. Key points:
|
|||||||
- Dark theme supports an `amoled` flag that flattens every surface tone to
|
- Dark theme supports an `amoled` flag that flattens every surface tone to
|
||||||
pure black — extend `colorScheme.copyWith(...)` there if a new surface
|
pure black — extend `colorScheme.copyWith(...)` there if a new surface
|
||||||
role needs the same treatment, don't hardcode `Colors.black` at call sites.
|
role needs the same treatment, don't hardcode `Colors.black` at call sites.
|
||||||
|
- **Scrollbars**: a project-wide `scrollbarTheme` (`AppTheme._scrollbarTheme`)
|
||||||
|
gives every `Scrollbar` in the app a thick, rounded, always-visible,
|
||||||
|
draggable thumb (Android fast-scroll style) derived from
|
||||||
|
`colorScheme.onSurfaceVariant` — don't pass per-instance `thickness`/
|
||||||
|
`radius`/`thumbVisibility`/`interactive`, just wrap scrollable content in a
|
||||||
|
plain `Scrollbar(child: ...)` (pass `controller:` matching the scrollable's
|
||||||
|
own when one exists) and it picks up the theme automatically.
|
||||||
|
|
||||||
## Reusable chrome
|
## Reusable chrome
|
||||||
|
|
||||||
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||||||
the blurred/translucent pill background shared by all floating chrome
|
the blurred/translucent pill background shared by all floating chrome
|
||||||
(bottom nav bar, media-viewer action bar). Reuse this for any new floating
|
(bottom nav bar, media-viewer top/bottom bars and video transport
|
||||||
overlay instead of building a new blur/shadow combo.
|
controls). Reuse this for any new floating overlay instead of building a
|
||||||
|
new blur/shadow combo.
|
||||||
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
|
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
|
||||||
main tab bar; opacity/blur are user-adjustable settings
|
main tab bar; opacity/blur are user-adjustable settings
|
||||||
(`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull
|
(`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull
|
||||||
new adjustable visual knobs from the provider the same way rather than
|
new adjustable visual knobs from the provider the same way rather than
|
||||||
hardcoding them.
|
hardcoding them.
|
||||||
|
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||||
|
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
|
||||||
|
`architecture.md`); also where the pull-to-refresh gesture thresholds and
|
||||||
|
the classic Material refresh spinner live.
|
||||||
|
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||||
|
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||||
|
for the video player's seek bar, plus a perpetually-animated
|
||||||
|
`SeekBarPreview` wrapper used by the Settings style picker so every
|
||||||
|
preview always matches the real widget exactly (same painter, just fed
|
||||||
|
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
||||||
|
forking the painter.
|
||||||
|
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
||||||
|
back button + filename, the bottom action bar, the video transport
|
||||||
|
controls) all share one small hand-rolled icon-button pattern
|
||||||
|
(`_ActionIconButton`: `InkWell` + `Icon` at a fixed 22px, colored from
|
||||||
|
`colorScheme.onSurface` unless overridden) rather than plain `IconButton`s
|
||||||
|
— match this instead of adding a bare `IconButton` in that screen, since a
|
||||||
|
default-styled one visibly stands out against the rest (this was a real
|
||||||
|
bug: an unstyled back button read as "too large" next to everything else).
|
||||||
|
- A title/label that might overflow a fixed-width chrome bar (e.g. the media
|
||||||
|
viewer's filename) should use `_MarqueeTitle`-style logic — measure with
|
||||||
|
`TextPainter` first and only switch to a scrolling `Marquee` when the text
|
||||||
|
actually doesn't fit, rather than marqueeing unconditionally.
|
||||||
- Icons: prefer `Icons.*_rounded` (matches the rest of the app) or
|
- Icons: prefer `Icons.*_rounded` (matches the rest of the app) or
|
||||||
`material_symbols_icons` where Material Symbols are already in use; avoid
|
`material_symbols_icons` where Material Symbols are already in use; avoid
|
||||||
mixing in the sharp/outlined default set.
|
mixing in the sharp/outlined default set.
|
||||||
|
|||||||
@@ -22,3 +22,13 @@ relevant file(s) before working in that area rather than loading all of them:
|
|||||||
flutter test # run tests
|
flutter test # run tests
|
||||||
flutter analyze # static analysis / lints
|
flutter analyze # static analysis / lints
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Keeping docs current
|
||||||
|
|
||||||
|
After making a change that affects architecture, server integration,
|
||||||
|
styling conventions, or code standards, update the relevant file(s) in
|
||||||
|
`.claude/context/` in the same session — don't leave documentation to drift
|
||||||
|
from the code. This applies whether the change is a new feature, a
|
||||||
|
refactor, or a convention shift (e.g. a new reusable widget, a changed
|
||||||
|
state-management pattern, a new dependency). If a change doesn't fit any
|
||||||
|
existing topic file, add a section rather than skipping the update.
|
||||||
|
|||||||
+18
-4
@@ -68,16 +68,30 @@ class _SplashView extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
// The monochrome adaptive-icon layer is a plain white silhouette
|
||||||
borderRadius: BorderRadius.circular(24),
|
// on transparent, meant to be tinted rather than shown as-is -
|
||||||
|
// srcIn recolors it to the theme's foreground so it reads
|
||||||
|
// correctly in both light and dark mode.
|
||||||
|
ColorFiltered(
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
colorScheme.onSurface,
|
||||||
|
BlendMode.srcIn,
|
||||||
|
),
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/icon/app_icon.png',
|
'assets/icon/app_icon_monochrome.png',
|
||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
CircularProgressIndicator(color: colorScheme.primary),
|
SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/// A saved Nextcloud login. The app password itself lives in secure
|
||||||
|
/// storage under a key derived from [id] - this model only carries the
|
||||||
|
/// non-secret identity fields that are safe to keep in SharedPreferences.
|
||||||
|
class SavedAccount {
|
||||||
|
final String id;
|
||||||
|
final String serverUrl;
|
||||||
|
final String username;
|
||||||
|
|
||||||
|
const SavedAccount({
|
||||||
|
required this.id,
|
||||||
|
required this.serverUrl,
|
||||||
|
required this.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A deterministic id from the server + username, so re-adding the same
|
||||||
|
/// account refreshes its stored password instead of creating a duplicate
|
||||||
|
/// entry in the saved-accounts list.
|
||||||
|
static String makeId(String serverUrl, String username) {
|
||||||
|
String slug(String s) =>
|
||||||
|
s.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '_');
|
||||||
|
return '${slug(serverUrl)}__${slug(username)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'serverUrl': serverUrl,
|
||||||
|
'username': username,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory SavedAccount.fromJson(Map<String, dynamic> json) => SavedAccount(
|
||||||
|
id: json['id'] as String,
|
||||||
|
serverUrl: json['serverUrl'] as String,
|
||||||
|
username: json['username'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
+412
-137
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -10,6 +9,8 @@ import '../models/nextcloud_file_version.dart';
|
|||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../models/nextcloud_share.dart';
|
import '../models/nextcloud_share.dart';
|
||||||
import '../models/nextcloud_sharee.dart';
|
import '../models/nextcloud_sharee.dart';
|
||||||
|
import '../models/saved_account.dart';
|
||||||
|
import '../services/account_store.dart';
|
||||||
import '../services/login_flow_service.dart';
|
import '../services/login_flow_service.dart';
|
||||||
import '../services/nextcloud_service.dart';
|
import '../services/nextcloud_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@@ -58,11 +59,6 @@ class _CachedDirectory {
|
|||||||
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
||||||
|
|
||||||
class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
static const _storage = FlutterSecureStorage();
|
|
||||||
static const _keyServerUrl = 'nc_server_url';
|
|
||||||
static const _keyLoginName = 'nc_login_name';
|
|
||||||
static const _keyAppPassword = 'nc_app_password';
|
|
||||||
|
|
||||||
// Cached UI settings/toggles (SharedPreferences keys)
|
// Cached UI settings/toggles (SharedPreferences keys)
|
||||||
static const _prefThemeMode = 'ui_theme_mode';
|
static const _prefThemeMode = 'ui_theme_mode';
|
||||||
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
|
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
|
||||||
@@ -190,10 +186,33 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
NextcloudService? _service;
|
NextcloudService? _service;
|
||||||
|
|
||||||
|
// Multi-account state
|
||||||
|
final AccountStore _accountStore = AccountStore();
|
||||||
|
List<SavedAccount> _accounts = [];
|
||||||
|
String? _activeAccountId;
|
||||||
|
// Bumped at the start of every account switch/removal/activation so an
|
||||||
|
// in-flight fetch from the account being left can recognize it's stale
|
||||||
|
// (by comparing against the generation it captured at its own start) and
|
||||||
|
// discard its result instead of writing it into the now-active account's
|
||||||
|
// state.
|
||||||
|
int _sessionGeneration = 0;
|
||||||
|
// UI-only signal for whether the in-progress login flow is "add another
|
||||||
|
// account" (started from Settings while already logged in) vs. the
|
||||||
|
// first/only login - ServerProvider itself doesn't branch persistence
|
||||||
|
// behavior on this, only the Add Account screen's own navigation does.
|
||||||
|
bool _addAccountFlowActive = false;
|
||||||
|
|
||||||
ServerProvider() {
|
ServerProvider() {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_restoreSession();
|
_init();
|
||||||
_loadPreferences();
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
final prefs = await _prefsFuture;
|
||||||
|
await _accountStore.migrateLegacyIfNeeded(prefs);
|
||||||
|
_accounts = _accountStore.loadAccounts(prefs);
|
||||||
|
_activeAccountId = _accountStore.loadActiveAccountId(prefs);
|
||||||
|
await Future.wait([_loadPreferences(), _restoreSession()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
/// Starts/stops the periodic folder-listing refresh as the app leaves and
|
||||||
@@ -227,6 +246,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
LoginFlowStatus get loginFlowStatus => _loginFlowStatus;
|
LoginFlowStatus get loginFlowStatus => _loginFlowStatus;
|
||||||
Uri? get pendingLoginUrl => _pendingLoginUrl;
|
Uri? get pendingLoginUrl => _pendingLoginUrl;
|
||||||
|
|
||||||
|
List<SavedAccount> get accounts => List.unmodifiable(_accounts);
|
||||||
|
String? get activeAccountId => _activeAccountId;
|
||||||
|
SavedAccount? get activeAccount =>
|
||||||
|
_accounts.where((a) => a.id == _activeAccountId).firstOrNull;
|
||||||
|
bool get isAddAccountFlow => _addAccountFlowActive;
|
||||||
|
|
||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
@@ -382,13 +407,13 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> _restoreSession() async {
|
Future<void> _restoreSession() async {
|
||||||
try {
|
try {
|
||||||
final server = await _storage.read(key: _keyServerUrl);
|
final id = _activeAccountId;
|
||||||
final loginName = await _storage.read(key: _keyLoginName);
|
if (id == null) return;
|
||||||
final appPassword = await _storage.read(key: _keyAppPassword);
|
final account = _accounts.where((a) => a.id == id).firstOrNull;
|
||||||
|
if (account == null) return;
|
||||||
if (server != null && loginName != null && appPassword != null) {
|
final password = await _accountStore.readPassword(id);
|
||||||
await _applyCredentials(server, loginName, appPassword, persist: false);
|
if (password == null) return;
|
||||||
}
|
await _applyCredentialsForAccount(account, password);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ServerProvider] Session restore failed: $e');
|
debugPrint('[ServerProvider] Session restore failed: $e');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -425,53 +450,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
||||||
_tapTabToScrollTop =
|
_tapTabToScrollTop =
|
||||||
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
||||||
_isGridView = prefs.getBool(_prefGridView) ?? _isGridView;
|
_applyAccountPrefs(prefs, _activeAccountId);
|
||||||
_showFavoritesOnlyFiles =
|
|
||||||
prefs.getBool(_prefShowFavoritesOnlyFiles) ?? _showFavoritesOnlyFiles;
|
|
||||||
_showFavoritesOnlyPhotos =
|
|
||||||
prefs.getBool(_prefShowFavoritesOnlyPhotos) ??
|
|
||||||
_showFavoritesOnlyPhotos;
|
|
||||||
final storageScopeName = prefs.getString(_prefStorageScope);
|
|
||||||
if (storageScopeName != null) {
|
|
||||||
_storageScope = StorageScope.values.firstWhere(
|
|
||||||
(s) => s.name == storageScopeName,
|
|
||||||
orElse: () => StorageScope.cloud,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_showHiddenFiles =
|
|
||||||
prefs.getBool(_prefShowHiddenFiles) ?? _showHiddenFiles;
|
|
||||||
_showHiddenPhotos =
|
|
||||||
prefs.getBool(_prefShowHiddenPhotos) ?? _showHiddenPhotos;
|
|
||||||
final sortFieldName = prefs.getString(_prefSortField);
|
|
||||||
if (sortFieldName != null) {
|
|
||||||
_photosSortField = FileSortField.values.firstWhere(
|
|
||||||
(f) => f.name == sortFieldName,
|
|
||||||
orElse: () => FileSortField.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_photosSortAscending =
|
|
||||||
prefs.getBool(_prefSortAscending) ?? _photosSortAscending;
|
|
||||||
|
|
||||||
final folderSortJson = prefs.getString(_prefFolderSort);
|
|
||||||
if (folderSortJson != null) {
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
|
|
||||||
for (final entry in decoded.entries) {
|
|
||||||
final value = entry.value as Map<String, dynamic>;
|
|
||||||
final fieldName = value['field'] as String?;
|
|
||||||
if (fieldName != null) {
|
|
||||||
_folderSortField[entry.key] = FileSortField.values.firstWhere(
|
|
||||||
(f) => f.name == fieldName,
|
|
||||||
orElse: () => FileSortField.name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final ascending = value['ascending'] as bool?;
|
|
||||||
if (ascending != null) _folderSortAscending[entry.key] = ascending;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[ServerProvider] Folder sort restore failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
final savedOrderNames = prefs.getStringList(_prefTabOrder);
|
||||||
if (savedOrderNames != null) {
|
if (savedOrderNames != null) {
|
||||||
@@ -531,15 +510,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
orElse: () => _swipeRightAction,
|
orElse: () => _swipeRightAction,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final cachePolicyName = prefs.getString(_prefCachePolicy);
|
|
||||||
if (cachePolicyName != null) {
|
|
||||||
_cachePolicy = CachePolicy.values.firstWhere(
|
|
||||||
(c) => c.name == cachePolicyName,
|
|
||||||
orElse: () => _cachePolicy,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_cacheIntervalMinutes =
|
|
||||||
prefs.getInt(_prefCacheIntervalMinutes) ?? _cacheIntervalMinutes;
|
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -548,34 +518,99 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persistCredentials(
|
/// (Re)loads every per-account browsing pref (grid/list view,
|
||||||
String server,
|
/// favorites-only, storage scope, show-hidden, sort, folder sort, cache
|
||||||
String loginName,
|
/// policy) for [accountId] - called once at startup and again on every
|
||||||
|
/// account switch. Resets to defaults when [accountId] is null (no saved
|
||||||
|
/// account yet).
|
||||||
|
void _applyAccountPrefs(SharedPreferences prefs, String? accountId) {
|
||||||
|
_folderSortField.clear();
|
||||||
|
_folderSortAscending.clear();
|
||||||
|
|
||||||
|
if (accountId == null) {
|
||||||
|
_isGridView = false;
|
||||||
|
_showFavoritesOnlyFiles = false;
|
||||||
|
_showFavoritesOnlyPhotos = false;
|
||||||
|
_storageScope = StorageScope.cloud;
|
||||||
|
_showHiddenFiles = false;
|
||||||
|
_showHiddenPhotos = false;
|
||||||
|
_photosSortField = FileSortField.name;
|
||||||
|
_photosSortAscending = true;
|
||||||
|
_cachePolicy = CachePolicy.never;
|
||||||
|
_cacheIntervalMinutes = 5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String k(String base) => _accountStore.accountPrefKey(accountId, base);
|
||||||
|
|
||||||
|
_isGridView = prefs.getBool(k(_prefGridView)) ?? false;
|
||||||
|
_showFavoritesOnlyFiles =
|
||||||
|
prefs.getBool(k(_prefShowFavoritesOnlyFiles)) ?? false;
|
||||||
|
_showFavoritesOnlyPhotos =
|
||||||
|
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||||
|
|
||||||
|
final storageScopeName = prefs.getString(k(_prefStorageScope));
|
||||||
|
_storageScope = StorageScope.values.firstWhere(
|
||||||
|
(s) => s.name == storageScopeName,
|
||||||
|
orElse: () => StorageScope.cloud,
|
||||||
|
);
|
||||||
|
|
||||||
|
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||||
|
_showHiddenPhotos = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||||
|
|
||||||
|
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||||
|
_photosSortField = FileSortField.values.firstWhere(
|
||||||
|
(f) => f.name == sortFieldName,
|
||||||
|
orElse: () => FileSortField.name,
|
||||||
|
);
|
||||||
|
_photosSortAscending = prefs.getBool(k(_prefSortAscending)) ?? true;
|
||||||
|
|
||||||
|
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||||
|
if (folderSortJson != null) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final value = entry.value as Map<String, dynamic>;
|
||||||
|
final fieldName = value['field'] as String?;
|
||||||
|
if (fieldName != null) {
|
||||||
|
_folderSortField[entry.key] = FileSortField.values.firstWhere(
|
||||||
|
(f) => f.name == fieldName,
|
||||||
|
orElse: () => FileSortField.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final ascending = value['ascending'] as bool?;
|
||||||
|
if (ascending != null) _folderSortAscending[entry.key] = ascending;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ServerProvider] Folder sort restore failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachePolicyName = prefs.getString(k(_prefCachePolicy));
|
||||||
|
_cachePolicy = CachePolicy.values.firstWhere(
|
||||||
|
(c) => c.name == cachePolicyName,
|
||||||
|
orElse: () => CachePolicy.never,
|
||||||
|
);
|
||||||
|
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifies [appPassword] for [account] and, on success, makes it the
|
||||||
|
/// live session. The password is expected to already be durably saved by
|
||||||
|
/// the caller (either freshly, via [_completeLoginFlow], or previously,
|
||||||
|
/// since this is also how a saved session is restored/switched to) -
|
||||||
|
/// this method only writes to [AccountStore] to drop a password that
|
||||||
|
/// turns out to no longer work.
|
||||||
|
Future<bool> _applyCredentialsForAccount(
|
||||||
|
SavedAccount account,
|
||||||
String appPassword,
|
String appPassword,
|
||||||
) async {
|
) async {
|
||||||
await _storage.write(key: _keyServerUrl, value: server);
|
final gen = _sessionGeneration;
|
||||||
await _storage.write(key: _keyLoginName, value: loginName);
|
|
||||||
await _storage.write(key: _keyAppPassword, value: appPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearPersistedCredentials() async {
|
|
||||||
await _storage.delete(key: _keyServerUrl);
|
|
||||||
await _storage.delete(key: _keyLoginName);
|
|
||||||
await _storage.delete(key: _keyAppPassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> _applyCredentials(
|
|
||||||
String serverUrl,
|
|
||||||
String username,
|
|
||||||
String appPassword, {
|
|
||||||
bool persist = true,
|
|
||||||
}) async {
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
_serverUrl = serverUrl;
|
_serverUrl = account.serverUrl;
|
||||||
_username = username;
|
_username = account.username;
|
||||||
_password = appPassword;
|
_password = appPassword;
|
||||||
|
|
||||||
_service = NextcloudService(
|
_service = NextcloudService(
|
||||||
@@ -586,27 +621,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final success = await _service!.testConnection();
|
final success = await _service!.testConnection();
|
||||||
|
if (gen != _sessionGeneration) return false;
|
||||||
if (success) {
|
if (success) {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
_currentFolderPath = '/';
|
_currentFolderPath = '/';
|
||||||
_pathStack = ['/'];
|
_pathStack = ['/'];
|
||||||
if (persist) {
|
|
||||||
await _persistCredentials(_serverUrl, _username, _password);
|
|
||||||
}
|
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
await refreshData();
|
await refreshData();
|
||||||
|
if (gen != _sessionGeneration) return false;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (gen != _sessionGeneration) return false;
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
if (!persist) {
|
// The stored app password for this account no longer works - drop it
|
||||||
// A previously-saved app password no longer works; drop it.
|
// so it doesn't keep silently failing on every future restore/switch.
|
||||||
await _clearPersistedCredentials();
|
await _accountStore.deletePassword(account.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gen != _sessionGeneration) return false;
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
_service = null;
|
_service = null;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -617,8 +652,16 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
|
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
|
||||||
/// URL, opens it in the system browser, then polls until the user
|
/// URL, opens it in the system browser, then polls until the user
|
||||||
/// authorizes and the server hands back a scoped app password. The app
|
/// authorizes and the server hands back a scoped app password. The app
|
||||||
/// never sees the user's real password.
|
/// never sees the user's real password. [addAccount] only marks the
|
||||||
Future<void> startLoginFlow(String serverUrl) async {
|
/// flow as "add another account" for [isAddAccountFlow] - the pushed Add
|
||||||
|
/// Account screen reads that to decide when to pop itself; this method's
|
||||||
|
/// own persistence behavior on success ([_completeLoginFlow]) is the same
|
||||||
|
/// either way (create-or-refresh the resulting account, then activate it).
|
||||||
|
Future<void> startLoginFlow(
|
||||||
|
String serverUrl, {
|
||||||
|
bool addAccount = false,
|
||||||
|
}) async {
|
||||||
|
_addAccountFlowActive = addAccount;
|
||||||
_loginFlowStatus = LoginFlowStatus.initiating;
|
_loginFlowStatus = LoginFlowStatus.initiating;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -666,11 +709,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_pollTimeoutTimer?.cancel();
|
_pollTimeoutTimer?.cancel();
|
||||||
_loginFlowStatus = LoginFlowStatus.idle;
|
_loginFlowStatus = LoginFlowStatus.idle;
|
||||||
_pendingLoginUrl = null;
|
_pendingLoginUrl = null;
|
||||||
await _applyCredentials(
|
await _completeLoginFlow(result);
|
||||||
result.serverUrl,
|
_addAccountFlowActive = false;
|
||||||
result.loginName,
|
|
||||||
result.appPassword,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} on http.ClientException catch (e) {
|
} on http.ClientException catch (e) {
|
||||||
// A single dropped connection (e.g. the network briefly
|
// A single dropped connection (e.g. the network briefly
|
||||||
@@ -705,6 +745,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
_pollTimeoutTimer?.cancel();
|
_pollTimeoutTimer?.cancel();
|
||||||
_pendingLoginUrl = null;
|
_pendingLoginUrl = null;
|
||||||
|
_addAccountFlowActive = false;
|
||||||
_loginFlowStatus = errorMessage != null
|
_loginFlowStatus = errorMessage != null
|
||||||
? LoginFlowStatus.error
|
? LoginFlowStatus.error
|
||||||
: LoginFlowStatus.idle;
|
: LoginFlowStatus.idle;
|
||||||
@@ -712,25 +753,192 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
/// Turns a completed Login Flow v2 result into a saved account (creating
|
||||||
_isLoggedIn = false;
|
/// it, or refreshing its password if it already existed - e.g. an
|
||||||
_serverUrl = '';
|
/// expired app password re-authenticated) and makes it the active
|
||||||
_username = '';
|
/// session. Used for both the first/only login and "add account".
|
||||||
_password = '';
|
Future<void> _completeLoginFlow(LoginFlowResult result) async {
|
||||||
|
final serverUrl = LoginFlowService.normalizeServerUrl(result.serverUrl);
|
||||||
|
final id = SavedAccount.makeId(serverUrl, result.loginName);
|
||||||
|
final account = SavedAccount(
|
||||||
|
id: id,
|
||||||
|
serverUrl: serverUrl,
|
||||||
|
username: result.loginName,
|
||||||
|
);
|
||||||
|
_accounts = [
|
||||||
|
for (final a in _accounts)
|
||||||
|
if (a.id != id) a,
|
||||||
|
account,
|
||||||
|
];
|
||||||
|
|
||||||
|
final prefs = await _prefsFuture;
|
||||||
|
await _accountStore.saveAccounts(prefs, _accounts);
|
||||||
|
await _accountStore.writePassword(id, result.appPassword);
|
||||||
|
await _activateAccount(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears every field that holds the *content* of whichever account is
|
||||||
|
/// currently active (files/photos/trash/shares/recent/service) - shared
|
||||||
|
/// by [_activateAccount] (about to load a different account's content)
|
||||||
|
/// and [removeAccount]'s "no accounts left" path (nothing left to load).
|
||||||
|
/// Deliberately does not touch [_isLoggedIn] - that's the caller's call.
|
||||||
|
void _clearActiveContent() {
|
||||||
_items = [];
|
_items = [];
|
||||||
_quota = null;
|
_quota = null;
|
||||||
_activities = [];
|
_activities = [];
|
||||||
_allMedia = [];
|
_allMedia = [];
|
||||||
|
_isMediaLoading = false;
|
||||||
|
_mediaErrorMessage = null;
|
||||||
_trashItems = [];
|
_trashItems = [];
|
||||||
|
_isTrashLoading = false;
|
||||||
|
_trashErrorMessage = null;
|
||||||
_shares = [];
|
_shares = [];
|
||||||
|
_isSharesLoading = false;
|
||||||
|
_sharesErrorMessage = null;
|
||||||
|
_sharesWithMe = false;
|
||||||
_recentItems = [];
|
_recentItems = [];
|
||||||
|
_isRecentLoading = false;
|
||||||
|
_recentErrorMessage = null;
|
||||||
|
_currentFolderPath = '/';
|
||||||
|
_pathStack = ['/'];
|
||||||
_service = null;
|
_service = null;
|
||||||
_directoryCache.clear();
|
|
||||||
_cacheRefreshTimer?.cancel();
|
|
||||||
await _clearPersistedCredentials();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shared engine behind switching accounts, falling back to another
|
||||||
|
/// account after removing the active one, and landing on the newly
|
||||||
|
/// created/refreshed account after a login flow completes: tears down
|
||||||
|
/// the outgoing account's live content (without ever setting
|
||||||
|
/// [isLoggedIn] false - see below), then loads the target account's own
|
||||||
|
/// prefs and credentials.
|
||||||
|
///
|
||||||
|
/// Non-goal, by design: no simultaneous multi-account state. This is a
|
||||||
|
/// full teardown-and-reload of the active session every time, exactly
|
||||||
|
/// like today's single-account [logout] already did - just without
|
||||||
|
/// touching any *other* saved account's stored credentials/prefs.
|
||||||
|
Future<void> _activateAccount(String accountId) async {
|
||||||
|
// Invalidates any fetch still in flight for the account being left, so
|
||||||
|
// a slow response can't land in the new account's state - see the
|
||||||
|
// `gen != _sessionGeneration` checks in refreshData/fetchAllMedia/
|
||||||
|
// fetchTrash/fetchShares/fetchRecent/_applyCredentialsForAccount.
|
||||||
|
_sessionGeneration++;
|
||||||
|
// A pending "add account" flow can't stay pending through a manual
|
||||||
|
// switch/cycle - simplicity over blocking the gesture.
|
||||||
|
if (_loginFlowStatus != LoginFlowStatus.idle) cancelLoginFlow();
|
||||||
|
|
||||||
|
_cacheRefreshTimer?.cancel();
|
||||||
|
_directoryCache.clear();
|
||||||
|
_clearActiveContent();
|
||||||
|
// Not `_isLoggedIn = false` - that would bounce main.dart's root
|
||||||
|
// routing through LoginView mid-switch. Each tab already shows its own
|
||||||
|
// spinner from `_isLoading`/`_isXLoading`, so this alone is enough to
|
||||||
|
// avoid flashing the outgoing account's stale content.
|
||||||
|
_isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_activeAccountId = accountId;
|
||||||
|
final prefs = await _prefsFuture;
|
||||||
|
await _accountStore.saveActiveAccountId(prefs, accountId);
|
||||||
|
_applyAccountPrefs(prefs, accountId);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final account = _accounts.where((a) => a.id == accountId).firstOrNull;
|
||||||
|
final password = account == null
|
||||||
|
? null
|
||||||
|
: await _accountStore.readPassword(accountId);
|
||||||
|
if (account == null || password == null) {
|
||||||
|
_isLoading = false;
|
||||||
|
_isLoggedIn = false;
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _applyCredentialsForAccount(account, password);
|
||||||
|
// The active account's Files listing is loaded synchronously above
|
||||||
|
// (inside _applyCredentialsForAccount -> refreshData); the other tabs
|
||||||
|
// stay mounted across the switch (MainShellView's IndexedStack) so
|
||||||
|
// their one-shot initState fetches won't naturally re-run - kick them
|
||||||
|
// off here instead.
|
||||||
|
unawaited(fetchAllMedia());
|
||||||
|
unawaited(fetchTrash());
|
||||||
|
unawaited(fetchShares());
|
||||||
|
unawaited(fetchRecent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switches to an already-saved account. No-op if it's already active or
|
||||||
|
/// unknown.
|
||||||
|
Future<void> switchAccount(String accountId) async {
|
||||||
|
if (accountId == _activeAccountId) return;
|
||||||
|
if (!_accounts.any((a) => a.id == accountId)) return;
|
||||||
|
await _activateAccount(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedAccount? _cycleAccount(int direction) {
|
||||||
|
if (_accounts.length < 2) return null;
|
||||||
|
final currentIndex = _accounts.indexWhere((a) => a.id == _activeAccountId);
|
||||||
|
final targetIndex = currentIndex == -1
|
||||||
|
? 0
|
||||||
|
: (currentIndex + direction) % _accounts.length;
|
||||||
|
final target =
|
||||||
|
_accounts[(targetIndex + _accounts.length) % _accounts.length];
|
||||||
|
unawaited(_activateAccount(target.id));
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycles to the next/previous saved account (by the order they were
|
||||||
|
/// added) - used by the avatar's swipe-up/down quick-switch gesture.
|
||||||
|
/// Returns the account it's switching to (synchronously, before the
|
||||||
|
/// switch's own network verification completes) so the caller can show
|
||||||
|
/// immediate feedback, or null if there's fewer than 2 saved accounts.
|
||||||
|
SavedAccount? cycleToNextAccount() => _cycleAccount(1);
|
||||||
|
SavedAccount? cycleToPreviousAccount() => _cycleAccount(-1);
|
||||||
|
|
||||||
|
/// Removes a saved account entirely: its stored password, its
|
||||||
|
/// namespaced prefs, and its entry in the saved-accounts list. If it was
|
||||||
|
/// the active account, falls back to another saved account, or - if none
|
||||||
|
/// remain - performs a full logout (the only path that sets
|
||||||
|
/// [isLoggedIn] false).
|
||||||
|
Future<void> removeAccount(String accountId) async {
|
||||||
|
final index = _accounts.indexWhere((a) => a.id == accountId);
|
||||||
|
if (index == -1) return;
|
||||||
|
final wasActive = accountId == _activeAccountId;
|
||||||
|
|
||||||
|
_accounts = [..._accounts]..removeAt(index);
|
||||||
|
final prefs = await _prefsFuture;
|
||||||
|
await _accountStore.saveAccounts(prefs, _accounts);
|
||||||
|
await _accountStore.deletePassword(accountId);
|
||||||
|
for (final key in AccountStore.perAccountPrefKeys) {
|
||||||
|
await prefs.remove(_accountStore.accountPrefKey(accountId, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasActive) {
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_accounts.isEmpty) {
|
||||||
|
_sessionGeneration++;
|
||||||
|
_activeAccountId = null;
|
||||||
|
await _accountStore.saveActiveAccountId(prefs, null);
|
||||||
|
_cacheRefreshTimer?.cancel();
|
||||||
|
_directoryCache.clear();
|
||||||
|
_clearActiveContent();
|
||||||
|
_isLoggedIn = false;
|
||||||
|
_serverUrl = '';
|
||||||
|
_username = '';
|
||||||
|
_password = '';
|
||||||
|
_applyAccountPrefs(prefs, null);
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _activateAccount(_accounts.first.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the active account. Kept as the app's one "Log Out" action -
|
||||||
|
/// with multiple accounts saved this falls back to another one instead
|
||||||
|
/// of ending the session, exactly like removing any other account would.
|
||||||
|
Future<void> logout() => removeAccount(_activeAccountId ?? '');
|
||||||
|
|
||||||
Future<void> refreshData() async {
|
Future<void> refreshData() async {
|
||||||
if (!_isLoggedIn || _service == null) {
|
if (!_isLoggedIn || _service == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -738,6 +946,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Captured so a response landing after an account switch mid-flight
|
||||||
|
// can recognize it's for an account the user has already left and
|
||||||
|
// discard itself instead of overwriting the new account's content.
|
||||||
|
final gen = _sessionGeneration;
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
@@ -748,7 +960,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_items = await _service!.fetchDirectory(_currentFolderPath);
|
final items = await _service!.fetchDirectory(_currentFolderPath);
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
|
_items = items;
|
||||||
_directoryCache[_currentFolderPath] = _CachedDirectory(
|
_directoryCache[_currentFolderPath] = _CachedDirectory(
|
||||||
_items,
|
_items,
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
@@ -773,13 +987,16 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
debugPrint('[ServerProvider] Activity fetch warning: $e');
|
debugPrint('[ServerProvider] Activity fetch warning: $e');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
debugPrint('[ServerProvider] Error fetching directory: $e');
|
debugPrint('[ServerProvider] Error fetching directory: $e');
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (gen == _sessionGeneration) {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<NextcloudItem>> searchFiles(String query) async {
|
Future<List<NextcloudItem>> searchFiles(String query) async {
|
||||||
if (_service == null) return [];
|
if (_service == null) return [];
|
||||||
@@ -789,22 +1006,28 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// Loads every image/video across the whole account for the Photos tab.
|
/// Loads every image/video across the whole account for the Photos tab.
|
||||||
Future<void> fetchAllMedia() async {
|
Future<void> fetchAllMedia() async {
|
||||||
if (!_isLoggedIn || _service == null) return;
|
if (!_isLoggedIn || _service == null) return;
|
||||||
|
final gen = _sessionGeneration;
|
||||||
|
|
||||||
_isMediaLoading = true;
|
_isMediaLoading = true;
|
||||||
_mediaErrorMessage = null;
|
_mediaErrorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_allMedia = await _service!.fetchAllMedia();
|
final media = await _service!.fetchAllMedia();
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
|
_allMedia = media;
|
||||||
debugPrint('[ServerProvider] Loaded ${_allMedia.length} media items');
|
debugPrint('[ServerProvider] Loaded ${_allMedia.length} media items');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
debugPrint('[ServerProvider] Error fetching all media: $e');
|
debugPrint('[ServerProvider] Error fetching all media: $e');
|
||||||
_mediaErrorMessage = e.toString().replaceAll('Exception: ', '');
|
_mediaErrorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (gen == _sessionGeneration) {
|
||||||
_isMediaLoading = false;
|
_isMediaLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> navigateToFolder(String path) async {
|
Future<void> navigateToFolder(String path) async {
|
||||||
_pathStack.add(path);
|
_pathStack.add(path);
|
||||||
@@ -873,26 +1096,42 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await refreshData();
|
await refreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
|
||||||
|
/// key. No-op if there's no active account (shouldn't normally happen -
|
||||||
|
/// these setters are only reachable from screens that require one).
|
||||||
|
void _persistAccountPref(
|
||||||
|
String baseKey,
|
||||||
|
void Function(SharedPreferences prefs, String key) write,
|
||||||
|
) {
|
||||||
|
final id = _activeAccountId;
|
||||||
|
if (id == null) return;
|
||||||
|
_prefsFuture.then(
|
||||||
|
(p) => write(p, _accountStore.accountPrefKey(id, baseKey)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void setGridView(bool value) {
|
void setGridView(bool value) {
|
||||||
if (_isGridView == value) return;
|
if (_isGridView == value) return;
|
||||||
_isGridView = value;
|
_isGridView = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setBool(_prefGridView, value));
|
_persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleFavoritesFilterFiles() {
|
void toggleFavoritesFilterFiles() {
|
||||||
_showFavoritesOnlyFiles = !_showFavoritesOnlyFiles;
|
_showFavoritesOnlyFiles = !_showFavoritesOnlyFiles;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then(
|
_persistAccountPref(
|
||||||
(p) => p.setBool(_prefShowFavoritesOnlyFiles, _showFavoritesOnlyFiles),
|
_prefShowFavoritesOnlyFiles,
|
||||||
|
(p, key) => p.setBool(key, _showFavoritesOnlyFiles),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleFavoritesFilterPhotos() {
|
void toggleFavoritesFilterPhotos() {
|
||||||
_showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos;
|
_showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then(
|
_persistAccountPref(
|
||||||
(p) => p.setBool(_prefShowFavoritesOnlyPhotos, _showFavoritesOnlyPhotos),
|
_prefShowFavoritesOnlyPhotos,
|
||||||
|
(p, key) => p.setBool(key, _showFavoritesOnlyPhotos),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,20 +1139,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (_storageScope == scope) return;
|
if (_storageScope == scope) return;
|
||||||
_storageScope = scope;
|
_storageScope = scope;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setString(_prefStorageScope, scope.name));
|
_persistAccountPref(
|
||||||
|
_prefStorageScope,
|
||||||
|
(p, key) => p.setString(key, scope.name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleShowHiddenFiles() {
|
void toggleShowHiddenFiles() {
|
||||||
_showHiddenFiles = !_showHiddenFiles;
|
_showHiddenFiles = !_showHiddenFiles;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setBool(_prefShowHiddenFiles, _showHiddenFiles));
|
_persistAccountPref(
|
||||||
|
_prefShowHiddenFiles,
|
||||||
|
(p, key) => p.setBool(key, _showHiddenFiles),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleShowHiddenPhotos() {
|
void toggleShowHiddenPhotos() {
|
||||||
_showHiddenPhotos = !_showHiddenPhotos;
|
_showHiddenPhotos = !_showHiddenPhotos;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then(
|
_persistAccountPref(
|
||||||
(p) => p.setBool(_prefShowHiddenPhotos, _showHiddenPhotos),
|
_prefShowHiddenPhotos,
|
||||||
|
(p, key) => p.setBool(key, _showHiddenPhotos),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,14 +1167,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (_photosSortField == field) return;
|
if (_photosSortField == field) return;
|
||||||
_photosSortField = field;
|
_photosSortField = field;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setString(_prefSortField, field.name));
|
_persistAccountPref(
|
||||||
|
_prefSortField,
|
||||||
|
(p, key) => p.setString(key, field.name),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void togglePhotosSortOrder() {
|
void togglePhotosSortOrder() {
|
||||||
_photosSortAscending = !_photosSortAscending;
|
_photosSortAscending = !_photosSortAscending;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then(
|
_persistAccountPref(
|
||||||
(p) => p.setBool(_prefSortAscending, _photosSortAscending),
|
_prefSortAscending,
|
||||||
|
(p, key) => p.setBool(key, _photosSortAscending),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,8 +1193,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
'ascending': _folderSortAscending[path],
|
'ascending': _folderSortAscending[path],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
_prefsFuture.then(
|
_persistAccountPref(
|
||||||
(p) => p.setString(_prefFolderSort, jsonEncode(combined)),
|
_prefFolderSort,
|
||||||
|
(p, key) => p.setString(key, jsonEncode(combined)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1294,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (_cachePolicy == policy) return;
|
if (_cachePolicy == policy) return;
|
||||||
_cachePolicy = policy;
|
_cachePolicy = policy;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setString(_prefCachePolicy, policy.name));
|
_persistAccountPref(
|
||||||
|
_prefCachePolicy,
|
||||||
|
(p, key) => p.setString(key, policy.name),
|
||||||
|
);
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,7 +1306,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (_cacheIntervalMinutes == clamped) return;
|
if (_cacheIntervalMinutes == clamped) return;
|
||||||
_cacheIntervalMinutes = clamped;
|
_cacheIntervalMinutes = clamped;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setInt(_prefCacheIntervalMinutes, clamped));
|
_persistAccountPref(
|
||||||
|
_prefCacheIntervalMinutes,
|
||||||
|
(p, key) => p.setInt(key, clamped),
|
||||||
|
);
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,21 +1352,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> fetchTrash() async {
|
Future<void> fetchTrash() async {
|
||||||
if (!_isLoggedIn || _service == null) return;
|
if (!_isLoggedIn || _service == null) return;
|
||||||
|
final gen = _sessionGeneration;
|
||||||
|
|
||||||
_isTrashLoading = true;
|
_isTrashLoading = true;
|
||||||
_trashErrorMessage = null;
|
_trashErrorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_trashItems = await _service!.fetchTrash();
|
final trash = await _service!.fetchTrash();
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
|
_trashItems = trash;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
debugPrint('[ServerProvider] Error fetching trash: $e');
|
debugPrint('[ServerProvider] Error fetching trash: $e');
|
||||||
_trashErrorMessage = e.toString().replaceAll('Exception: ', '');
|
_trashErrorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (gen == _sessionGeneration) {
|
||||||
_isTrashLoading = false;
|
_isTrashLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Restores a trashed item (identified by [NextcloudItem.path], the
|
/// Restores a trashed item (identified by [NextcloudItem.path], the
|
||||||
/// trash-relative name) back to where it was deleted from.
|
/// trash-relative name) back to where it was deleted from.
|
||||||
@@ -1136,21 +1399,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> fetchShares() async {
|
Future<void> fetchShares() async {
|
||||||
if (!_isLoggedIn || _service == null) return;
|
if (!_isLoggedIn || _service == null) return;
|
||||||
|
final gen = _sessionGeneration;
|
||||||
|
|
||||||
_isSharesLoading = true;
|
_isSharesLoading = true;
|
||||||
_sharesErrorMessage = null;
|
_sharesErrorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe);
|
final shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe);
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
|
_shares = shares;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
debugPrint('[ServerProvider] Error fetching shares: $e');
|
debugPrint('[ServerProvider] Error fetching shares: $e');
|
||||||
_sharesErrorMessage = e.toString().replaceAll('Exception: ', '');
|
_sharesErrorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (gen == _sessionGeneration) {
|
||||||
_isSharesLoading = false;
|
_isSharesLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Switches between "shared by me" and "shared with me" and refetches.
|
/// Switches between "shared by me" and "shared with me" and refetches.
|
||||||
void setSharesWithMe(bool value) {
|
void setSharesWithMe(bool value) {
|
||||||
@@ -1172,21 +1441,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
Future<void> fetchRecent() async {
|
Future<void> fetchRecent() async {
|
||||||
if (!_isLoggedIn || _service == null) return;
|
if (!_isLoggedIn || _service == null) return;
|
||||||
|
final gen = _sessionGeneration;
|
||||||
|
|
||||||
_isRecentLoading = true;
|
_isRecentLoading = true;
|
||||||
_recentErrorMessage = null;
|
_recentErrorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_recentItems = await _service!.fetchRecentFiles();
|
final recent = await _service!.fetchRecentFiles();
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
|
_recentItems = recent;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (gen != _sessionGeneration) return;
|
||||||
debugPrint('[ServerProvider] Error fetching recent files: $e');
|
debugPrint('[ServerProvider] Error fetching recent files: $e');
|
||||||
_recentErrorMessage = e.toString().replaceAll('Exception: ', '');
|
_recentErrorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (gen == _sessionGeneration) {
|
||||||
_isRecentLoading = false;
|
_isRecentLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> createFolder(String folderName) async {
|
Future<bool> createFolder(String folderName) async {
|
||||||
if (_service == null) return false;
|
if (_service == null) return false;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../models/saved_account.dart';
|
||||||
|
|
||||||
|
/// Persists the list of saved accounts, which one is active, and each
|
||||||
|
/// account's app password - plus a one-shot migration from the app's
|
||||||
|
/// original single-account storage schema (3 flat secure-storage keys and
|
||||||
|
/// a handful of un-namespaced SharedPreferences keys) into this one.
|
||||||
|
class AccountStore {
|
||||||
|
static const _storage = FlutterSecureStorage();
|
||||||
|
|
||||||
|
// Legacy single-account keys (pre-multi-account).
|
||||||
|
static const _keyLegacyServerUrl = 'nc_server_url';
|
||||||
|
static const _keyLegacyLoginName = 'nc_login_name';
|
||||||
|
static const _keyLegacyAppPassword = 'nc_app_password';
|
||||||
|
|
||||||
|
static const _passwordKeyPrefix = 'nc_app_password_';
|
||||||
|
|
||||||
|
static const _prefAccountsList = 'accounts_list';
|
||||||
|
static const _prefActiveAccountId = 'active_account_id';
|
||||||
|
static const _prefMigrationDone = 'account_migration_v1_done';
|
||||||
|
|
||||||
|
/// The per-account browsing prefs that get namespaced under
|
||||||
|
/// `acct_<id>_<key>` on migration/save - everything else (theme, tab
|
||||||
|
/// order, seek bar style, etc.) stays a global/app-wide pref, untouched
|
||||||
|
/// by account switching.
|
||||||
|
static const perAccountPrefKeys = [
|
||||||
|
'ui_grid_view',
|
||||||
|
'ui_show_favorites_only',
|
||||||
|
'ui_show_favorites_only_photos',
|
||||||
|
'ui_storage_scope',
|
||||||
|
'ui_show_hidden',
|
||||||
|
'ui_show_hidden_photos',
|
||||||
|
'ui_sort_field',
|
||||||
|
'ui_sort_ascending',
|
||||||
|
'ui_folder_sort',
|
||||||
|
'ui_cache_policy',
|
||||||
|
'ui_cache_interval_minutes',
|
||||||
|
];
|
||||||
|
|
||||||
|
String accountPrefKey(String accountId, String baseKey) =>
|
||||||
|
'acct_${accountId}_$baseKey';
|
||||||
|
|
||||||
|
/// Runs once, ever: if a legacy single-account login is found and this
|
||||||
|
/// hasn't already migrated, turns it into the first saved (and active)
|
||||||
|
/// account, copies its browsing prefs to their namespaced keys, and
|
||||||
|
/// deletes the legacy keys. Safe to call on every launch - it's a no-op
|
||||||
|
/// once the migration marker is set, including on a fresh install with
|
||||||
|
/// nothing to migrate.
|
||||||
|
Future<void> migrateLegacyIfNeeded(SharedPreferences prefs) async {
|
||||||
|
if (prefs.getBool(_prefMigrationDone) == true) return;
|
||||||
|
try {
|
||||||
|
final serverUrl = await _storage.read(key: _keyLegacyServerUrl);
|
||||||
|
final username = await _storage.read(key: _keyLegacyLoginName);
|
||||||
|
final appPassword = await _storage.read(key: _keyLegacyAppPassword);
|
||||||
|
|
||||||
|
if (serverUrl != null && username != null && appPassword != null) {
|
||||||
|
final id = SavedAccount.makeId(serverUrl, username);
|
||||||
|
await _storage.write(key: '$_passwordKeyPrefix$id', value: appPassword);
|
||||||
|
|
||||||
|
for (final key in perAccountPrefKeys) {
|
||||||
|
final value = prefs.get(key);
|
||||||
|
if (value == null) continue;
|
||||||
|
final namespacedKey = accountPrefKey(id, key);
|
||||||
|
if (value is bool) {
|
||||||
|
await prefs.setBool(namespacedKey, value);
|
||||||
|
} else if (value is int) {
|
||||||
|
await prefs.setInt(namespacedKey, value);
|
||||||
|
} else if (value is double) {
|
||||||
|
await prefs.setDouble(namespacedKey, value);
|
||||||
|
} else if (value is String) {
|
||||||
|
await prefs.setString(namespacedKey, value);
|
||||||
|
} else if (value is List<String>) {
|
||||||
|
await prefs.setStringList(namespacedKey, value);
|
||||||
|
}
|
||||||
|
await prefs.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveAccounts(prefs, [
|
||||||
|
SavedAccount(id: id, serverUrl: serverUrl, username: username),
|
||||||
|
]);
|
||||||
|
await saveActiveAccountId(prefs, id);
|
||||||
|
|
||||||
|
await _storage.delete(key: _keyLegacyServerUrl);
|
||||||
|
await _storage.delete(key: _keyLegacyLoginName);
|
||||||
|
await _storage.delete(key: _keyLegacyAppPassword);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Set unconditionally, even when there was nothing to migrate, so
|
||||||
|
// this block truly never runs again.
|
||||||
|
await prefs.setBool(_prefMigrationDone, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SavedAccount> loadAccounts(SharedPreferences prefs) {
|
||||||
|
final json = prefs.getString(_prefAccountsList);
|
||||||
|
if (json == null) return [];
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(json) as List<dynamic>;
|
||||||
|
return decoded
|
||||||
|
.map((e) => SavedAccount.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveAccounts(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
List<SavedAccount> accounts,
|
||||||
|
) async {
|
||||||
|
await prefs.setString(
|
||||||
|
_prefAccountsList,
|
||||||
|
jsonEncode(accounts.map((a) => a.toJson()).toList()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? loadActiveAccountId(SharedPreferences prefs) =>
|
||||||
|
prefs.getString(_prefActiveAccountId);
|
||||||
|
|
||||||
|
Future<void> saveActiveAccountId(SharedPreferences prefs, String? id) async {
|
||||||
|
if (id == null) {
|
||||||
|
await prefs.remove(_prefActiveAccountId);
|
||||||
|
} else {
|
||||||
|
await prefs.setString(_prefActiveAccountId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> readPassword(String accountId) =>
|
||||||
|
_storage.read(key: '$_passwordKeyPrefix$accountId');
|
||||||
|
|
||||||
|
Future<void> writePassword(String accountId, String password) =>
|
||||||
|
_storage.write(key: '$_passwordKeyPrefix$accountId', value: password);
|
||||||
|
|
||||||
|
Future<void> deletePassword(String accountId) =>
|
||||||
|
_storage.delete(key: '$_passwordKeyPrefix$accountId');
|
||||||
|
}
|
||||||
@@ -30,6 +30,37 @@ class AppTheme {
|
|||||||
Color(0xFF2E7D32), // Emerald Green
|
Color(0xFF2E7D32), // Emerald Green
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Flutter's default Scrollbar renders a ~6px hairline that's easy to miss
|
||||||
|
// and hard to grab. This tunes it to read like Android's standard
|
||||||
|
// draggable fast-scroll thumb: thicker, pill-shaped, and always at least
|
||||||
|
// faintly visible (not just flashing in on scroll), while still getting
|
||||||
|
// a touch more visible/opaque while actively hovered or dragged.
|
||||||
|
static ScrollbarThemeData _scrollbarTheme(ColorScheme colorScheme) {
|
||||||
|
final thumbColor = colorScheme.onSurfaceVariant;
|
||||||
|
return ScrollbarThemeData(
|
||||||
|
thickness: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.dragged) ||
|
||||||
|
states.contains(WidgetState.hovered)) {
|
||||||
|
return 10.0;
|
||||||
|
}
|
||||||
|
return 8.0;
|
||||||
|
}),
|
||||||
|
radius: const Radius.circular(8),
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.dragged)) {
|
||||||
|
return thumbColor.withValues(alpha: 0.9);
|
||||||
|
}
|
||||||
|
if (states.contains(WidgetState.hovered)) {
|
||||||
|
return thumbColor.withValues(alpha: 0.8);
|
||||||
|
}
|
||||||
|
return thumbColor.withValues(alpha: 0.6);
|
||||||
|
}),
|
||||||
|
thumbVisibility: const WidgetStatePropertyAll(true),
|
||||||
|
trackVisibility: const WidgetStatePropertyAll(false),
|
||||||
|
interactive: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static ThemeData light(
|
static ThemeData light(
|
||||||
Color seedColor, {
|
Color seedColor, {
|
||||||
ColorScheme? dynamicScheme,
|
ColorScheme? dynamicScheme,
|
||||||
@@ -49,6 +80,7 @@ class AppTheme {
|
|||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
pageTransitionsTheme: _pageTransitionsTheme,
|
pageTransitionsTheme: _pageTransitionsTheme,
|
||||||
sliderTheme: _sliderTheme,
|
sliderTheme: _sliderTheme,
|
||||||
|
scrollbarTheme: _scrollbarTheme(colorScheme),
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
|
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
|
||||||
.copyWith(
|
.copyWith(
|
||||||
headlineMedium: GoogleFonts.inter(
|
headlineMedium: GoogleFonts.inter(
|
||||||
@@ -115,6 +147,7 @@ class AppTheme {
|
|||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
pageTransitionsTheme: _pageTransitionsTheme,
|
pageTransitionsTheme: _pageTransitionsTheme,
|
||||||
sliderTheme: _sliderTheme,
|
sliderTheme: _sliderTheme,
|
||||||
|
scrollbarTheme: _scrollbarTheme(colorScheme),
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
|
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
|
||||||
.copyWith(
|
.copyWith(
|
||||||
headlineMedium: GoogleFonts.inter(
|
headlineMedium: GoogleFonts.inter(
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import '../models/saved_account.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../widgets/frosted_glass_container.dart';
|
import '../widgets/frosted_glass_container.dart';
|
||||||
import '../widgets/seek_bar_painter.dart';
|
import '../widgets/seek_bar_painter.dart';
|
||||||
|
import 'login_view.dart';
|
||||||
|
|
||||||
class AccountView extends StatelessWidget {
|
class AccountView extends StatelessWidget {
|
||||||
const AccountView({super.key});
|
const AccountView({super.key});
|
||||||
@@ -177,6 +179,17 @@ class AccountView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Accounts Section
|
||||||
|
Text(
|
||||||
|
'Accounts',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const _AccountsCard(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Server Credentials Section
|
// Server Credentials Section
|
||||||
Text(
|
Text(
|
||||||
'Server Connection Info',
|
'Server Connection Info',
|
||||||
@@ -465,6 +478,138 @@ class AccountView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists every saved account, letting the user switch to an inactive one,
|
||||||
|
/// remove any of them, or add another via [LoginView] pushed in "add
|
||||||
|
/// account" mode.
|
||||||
|
class _AccountsCard extends StatelessWidget {
|
||||||
|
const _AccountsCard();
|
||||||
|
|
||||||
|
Future<void> _confirmRemove(
|
||||||
|
BuildContext context,
|
||||||
|
ServerProvider provider,
|
||||||
|
SavedAccount account,
|
||||||
|
) async {
|
||||||
|
final isActive = account.id == provider.activeAccountId;
|
||||||
|
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Remove Account'),
|
||||||
|
content: Text(
|
||||||
|
isActive && provider.accounts.length > 1
|
||||||
|
? 'Remove ${account.username} ($host)? Another saved account will become active.'
|
||||||
|
: 'Remove ${account.username} ($host)? You can add it again later.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
await provider.removeAccount(account.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<ServerProvider>();
|
||||||
|
final accounts = provider.accounts;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
for (final account in accounts) ...[
|
||||||
|
_AccountRow(
|
||||||
|
account: account,
|
||||||
|
isActive: account.id == provider.activeAccountId,
|
||||||
|
onTap: account.id == provider.activeAccountId
|
||||||
|
? null
|
||||||
|
: () => provider.switchAccount(account.id),
|
||||||
|
onRemove: () => _confirmRemove(context, provider, account),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||||
|
],
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add_circle_outline_rounded),
|
||||||
|
title: const Text('Add account'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const LoginView(isAddingAccount: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountRow extends StatelessWidget {
|
||||||
|
final SavedAccount account;
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
|
const _AccountRow({
|
||||||
|
required this.account,
|
||||||
|
required this.isActive,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
|
||||||
|
final initial = account.username.isNotEmpty
|
||||||
|
? account.username[0].toUpperCase()
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: colorScheme.primary,
|
||||||
|
child: Text(
|
||||||
|
initial,
|
||||||
|
style: TextStyle(
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(account.username),
|
||||||
|
subtitle: Text(host),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isActive)
|
||||||
|
Icon(Icons.check_circle_rounded, color: colorScheme.primary),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline_rounded),
|
||||||
|
tooltip: 'Remove account',
|
||||||
|
onPressed: onRemove,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _BottomBarAppearanceCard extends StatefulWidget {
|
class _BottomBarAppearanceCard extends StatefulWidget {
|
||||||
const _BottomBarAppearanceCard();
|
const _BottomBarAppearanceCard();
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ class ActivityView extends StatelessWidget {
|
|||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
|
||||||
if (activities.isEmpty)
|
if (provider.isLoading && activities.isEmpty)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (activities.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: Center(
|
child: Center(
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
|
|
||||||
class LoginView extends StatefulWidget {
|
class LoginView extends StatefulWidget {
|
||||||
const LoginView({super.key});
|
/// True when this is pushed from Settings ("Add account") on top of an
|
||||||
|
/// already-logged-in session, rather than shown as the app's root screen
|
||||||
|
/// with no account yet. Adds an AppBar/back affordance and auto-pops once
|
||||||
|
/// the new account becomes active.
|
||||||
|
final bool isAddingAccount;
|
||||||
|
|
||||||
|
const LoginView({super.key, this.isAddingAccount = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginView> createState() => _LoginViewState();
|
State<LoginView> createState() => _LoginViewState();
|
||||||
@@ -12,6 +18,16 @@ class LoginView extends StatefulWidget {
|
|||||||
class _LoginViewState extends State<LoginView> {
|
class _LoginViewState extends State<LoginView> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _urlController = TextEditingController();
|
final _urlController = TextEditingController();
|
||||||
|
String? _originalActiveAccountId;
|
||||||
|
bool _popped = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.isAddingAccount) {
|
||||||
|
_originalActiveAccountId = context.read<ServerProvider>().activeAccountId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -22,7 +38,10 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
void _handleContinue() {
|
void _handleContinue() {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
context.read<ServerProvider>().startLoginFlow(_urlController.text.trim());
|
context.read<ServerProvider>().startLoginFlow(
|
||||||
|
_urlController.text.trim(),
|
||||||
|
addAccount: widget.isAddingAccount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -35,7 +54,22 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
||||||
final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating;
|
final isInitiating = provider.loginFlowStatus == LoginFlowStatus.initiating;
|
||||||
|
|
||||||
return Scaffold(
|
// A new/refreshed account has just become active - pop back to
|
||||||
|
// Settings rather than leaving this form sitting on top of it.
|
||||||
|
if (widget.isAddingAccount &&
|
||||||
|
!_popped &&
|
||||||
|
provider.loginFlowStatus == LoginFlowStatus.idle &&
|
||||||
|
provider.activeAccountId != _originalActiveAccountId) {
|
||||||
|
_popped = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) Navigator.of(context).pop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = Scaffold(
|
||||||
|
appBar: widget.isAddingAccount
|
||||||
|
? AppBar(title: const Text('Add Account'))
|
||||||
|
: null,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -65,6 +99,21 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!widget.isAddingAccount) return body;
|
||||||
|
|
||||||
|
// Backing out mid-flow (system back/swipe-back, not just the explicit
|
||||||
|
// Cancel button in _WaitingForBrowser) should cancel the pending login
|
||||||
|
// flow rather than leaving its poll timer running after this screen is
|
||||||
|
// gone - this view could never be popped before "add account" existed,
|
||||||
|
// so that case wasn't reachable until now.
|
||||||
|
return PopScope(
|
||||||
|
canPop: provider.loginFlowStatus != LoginFlowStatus.awaitingBrowser,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) provider.cancelLoginFlow();
|
||||||
|
},
|
||||||
|
child: body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/server_provider.dart';
|
import '../providers/server_provider.dart';
|
||||||
import '../views/account_view.dart';
|
import '../views/account_view.dart';
|
||||||
|
|
||||||
/// Top-bar avatar button that opens the account screen. Replaces the old
|
/// Top-bar avatar button that opens the account screen. Replaces the old
|
||||||
/// "Account" bottom-nav destination, matching how Google Drive surfaces
|
/// "Account" bottom-nav destination, matching how Google Drive surfaces
|
||||||
/// account access from a profile picture in the app bar.
|
/// account access from a profile picture in the app bar. Also doubles as a
|
||||||
|
/// quick account switcher: swiping up/down on it cycles to the next/
|
||||||
|
/// previous saved account immediately, with no confirmation - a shortcut
|
||||||
|
/// alongside the full switcher list in Settings.
|
||||||
class ProfileAvatarButton extends StatelessWidget {
|
class ProfileAvatarButton extends StatelessWidget {
|
||||||
const ProfileAvatarButton({super.key});
|
const ProfileAvatarButton({super.key});
|
||||||
|
|
||||||
|
void _handleVerticalSwipe(BuildContext context, DragEndDetails details) {
|
||||||
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
if (velocity.abs() < 250) return;
|
||||||
|
|
||||||
|
final provider = context.read<ServerProvider>();
|
||||||
|
final target = velocity < 0
|
||||||
|
? provider.cycleToNextAccount()
|
||||||
|
: provider.cycleToPreviousAccount();
|
||||||
|
if (target == null) return;
|
||||||
|
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Switched to ${target.username}'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<ServerProvider>();
|
final provider = context.watch<ServerProvider>();
|
||||||
@@ -19,6 +42,8 @@ class ProfileAvatarButton extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(right: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: GestureDetector(
|
||||||
|
onVerticalDragEnd: (details) => _handleVerticalSwipe(context, details),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
tooltip: 'Settings',
|
tooltip: 'Settings',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -40,6 +65,7 @@ class ProfileAvatarButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ flutter:
|
|||||||
|
|
||||||
assets:
|
assets:
|
||||||
- assets/icon/app_icon.png
|
- assets/icon/app_icon.png
|
||||||
|
- assets/icon/app_icon_monochrome.png
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
|
|||||||
Reference in New Issue
Block a user