diff --git a/.claude/context/architecture.md b/.claude/context/architecture.md index 9be043a..60b02d4 100644 --- a/.claude/context/architecture.md +++ b/.claude/context/architecture.md @@ -4,12 +4,18 @@ ``` lib/ - main.dart # app root, theme wiring, top-level navigation switch - models/ # plain data classes (NextcloudItem, NextcloudShare, ...) + main.dart # app root, theme wiring, top-level navigation switch, + # MainShellView (bottom-nav shell), share-intent listener + models/ # plain data classes (NextcloudItem, NextcloudShare, + # SavedAccount, AppTab, ...) providers/ # ServerProvider — the single app-wide ChangeNotifier - services/ # network/IO: NextcloudService, LoginFlowService + services/ # network/IO: NextcloudService, LoginFlowService, + # AccountStore 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 details/ # the file-details bottom sheet and its tabs ``` @@ -22,38 +28,83 @@ read via `context.watch`/`context.read`. ## State management -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 -`ChangeNotifierProvider`. It owns: +There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart) +(~1500 lines). It is created once in `main()` and provided at the root with +`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) -- all fetched data (`items`, `quota`, `activities`) -- navigation-within-files state (`currentFolderPath`, `pathStack`) -- UI settings that persist across launches (theme mode, seed color, dynamic - color toggle, bottom-bar opacity/blur, sort/filter/view-mode prefs) +- all fetched data, scoped to the active account (`items`, `quota`, + `activities`, `photoItems`, `trashItems`, `shares`, `recentItems`) — all + torn down and refetched fresh on every account switch (no simultaneous + 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 + -a method that mutates it and calls `notifyListeners()`. 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. +New **global** state belongs on `ServerProvider` as a private field + getter ++ a method that mutates it, calls `notifyListeners()`, and persists via +`_prefsFuture`. New **per-account** state follows the same shape but +persists through `_persistAccountPref` (namespaces the pref key under +`acct__...` 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` -methods directly, which call `NextcloudService`/`LoginFlowService`. +methods directly, which call `NextcloudService`/`LoginFlowService`/ +`AccountStore`. ## Navigation / screen flow `main.dart`'s `NextcloudApp` picks the app's `home` screen from provider state, no named routes: -- `provider.isRestoringSession` → `_SplashView` (spinner while - `flutter_secure_storage`/`shared_preferences` are read on startup) +- `provider.isRestoringSession` → `_SplashView` (monochrome app icon, + 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 - 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` -`MainShellView` is a bottom-nav `IndexedStack` with three persistent tabs — -Files, Photos, Activity — each keeping its own `ScrollController` so state -(scroll position, `IndexedStack`'s built-but-hidden trees) survives tab -switches. `SearchView` and the file-details sheet are pushed on top via -`Navigator`/`showModalBottomSheet` rather than being tabs. +`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files, +Photos, Activity, Trash, Shares, Recent — user-configurable (order, +visibility up to `maxVisibleTabs`, default tab) via `AppTab`/`ServerProvider` +and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`). +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. diff --git a/.claude/context/server.md b/.claude/context/server.md index de9d130..20e4179 100644 --- a/.claude/context/server.md +++ b/.claude/context/server.md @@ -9,12 +9,18 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart): 1. `LoginFlowService.initiate(serverUrl)` POSTs to `{server}/index.php/login/v2`, gets back a browser login URL + a poll endpoint/token. -2. The app opens the login URL in the system browser (`url_launcher`); the - user authenticates and authorizes there. +2. The app opens the login URL in a Chrome Custom Tab (`url_launcher`, + `LaunchMode.inAppBrowserView` — real Chrome, so saved passwords/autofill + work, unlike Flutter's own embedded web view); the user authenticates and + authorizes there. There's no way to close the tab automatically on + success (Login Flow v2 never redirects back into the app, and a Custom + Tab belongs to Chrome's own task) — the user switches back manually. 3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every 2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in `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 used for every subsequent request — real user passwords are never in 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 flow rationale before changing it. +`startLoginFlow(serverUrl, {addAccount = false})` is reused verbatim for +both the first/only login and "add another account" (pushed from Settings +while already logged into a different account, `LoginView(isAddingAccount: +true)`) — `addAccount` only flags `isAddAccountFlow` for the UI (so the +pushed screen knows to auto-pop on success and cancel the flow on +back-swipe); the persistence path on success is identical either way, see +below. + ## Talking to the server [`NextcloudService`](../../lib/services/nextcloud_service.dart) is the client for an authenticated session — constructed with `serverUrl` + `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 hand-rolled XML request body and `package:xml` for parsing responses — there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this file exist because WebDAV responses use RFC 1123 dates and either bare paths or full URLs for `href`; reuse them rather than re-deriving. -- **Everything else** (shares, activity, trash, favorites, quota, user info) - goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON in, with the - `OCS-APIRequest: true` header required on every OCS call. +- **Everything else** (shares, activity, trash, favorites, quota, user info, + file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON + in, with the `OCS-APIRequest: true` header required on every OCS call. - Auth header is HTTP Basic (`username:appPassword`, base64), built in `_headers`/exposed as `authHeaders` for widgets that need to hit URLs directly (e.g. `Image.network(url, headers: service.authHeaders)` for thumbnails/previews). - Downloads stream through `Dio` (`downloadToFile`) for progress callbacks; small in-app previews (text/PDF) 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 - `flutter_secure_storage` — OS keychain/keystore-backed, never - `shared_preferences`. -- **UI/app preferences** (theme mode, seed color, dynamic-color toggle, - bottom-bar opacity/blur, grid vs. list, sort field, hidden-files toggle, - etc.) live in `shared_preferences` — see the `_pref*` key constants at the - top of `server_provider.dart`. -- On startup, `ServerProvider._restoreSession()` reads the secure-storage - keys and, if all three are present, rebuilds a `NextcloudService` without - re-hitting the login flow (`_applyCredentials(..., persist: false)`). - `isRestoringSession` gates the splash screen until this resolves — see - `standards.md` for why widget tests must mock both storage channels - rather than relying on this async path throwing naturally. +[`AccountStore`](../../lib/services/account_store.dart) owns everything +account-identity-related; `ServerProvider` owns everything about which +account is *currently* live (see `architecture.md`). + +- **Per-account secrets**: one `flutter_secure_storage` key per account, + `nc_app_password_` — never `shared_preferences`. `accountId` is + deterministic (`SavedAccount.makeId(serverUrl, username)`, a slug of both), + so re-adding the same account refreshes its password instead of creating a + duplicate. +- **Account identity list** (non-secret: id/serverUrl/username) and + **which one is active** live in `shared_preferences` as `accounts_list` + (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__` + 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. diff --git a/.claude/context/standards.md b/.claude/context/standards.md index 57c00dc..ed4f073 100644 --- a/.claude/context/standards.md +++ b/.claude/context/standards.md @@ -33,6 +33,25 @@ class/method already makes obvious. and `context.read()` for one-off calls from callbacks (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 - Widget tests must mock platform channels that the app touches on startup diff --git a/.claude/context/styling.md b/.claude/context/styling.md index 6812968..a670fd2 100644 --- a/.claude/context/styling.md +++ b/.claude/context/styling.md @@ -29,18 +29,49 @@ widgets. Key points: - Dark theme supports an `amoled` flag that flattens every surface tone to pure black — extend `colorScheme.copyWith(...)` there if a new surface 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 - [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) — the blurred/translucent pill background shared by all floating chrome - (bottom nav bar, media-viewer action bar). Reuse this for any new floating - overlay instead of building a new blur/shadow combo. + (bottom nav bar, media-viewer top/bottom bars and video transport + controls). Reuse this for any new floating overlay instead of building a + new blur/shadow combo. - [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the main tab bar; opacity/blur are user-adjustable settings (`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull new adjustable visual knobs from the provider the same way rather than 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 `material_symbols_icons` where Material Symbols are already in use; avoid mixing in the sharp/outlined default set. diff --git a/CLAUDE.md b/CLAUDE.md index c655a78..af6698f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,3 +22,13 @@ relevant file(s) before working in that area rather than loading all of them: flutter test # run tests 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. diff --git a/lib/main.dart b/lib/main.dart index ba28e5f..1c1685f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -68,16 +68,30 @@ class _SplashView extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(24), + // The monochrome adaptive-icon layer is a plain white silhouette + // 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( - 'assets/icon/app_icon.png', + 'assets/icon/app_icon_monochrome.png', width: 72, height: 72, ), ), const SizedBox(height: 24), - CircularProgressIndicator(color: colorScheme.primary), + SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: colorScheme.primary, + ), + ), ], ), ), diff --git a/lib/models/saved_account.dart b/lib/models/saved_account.dart new file mode 100644 index 0000000..4a9cfe1 --- /dev/null +++ b/lib/models/saved_account.dart @@ -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 toJson() => { + 'id': id, + 'serverUrl': serverUrl, + 'username': username, + }; + + factory SavedAccount.fromJson(Map json) => SavedAccount( + id: json['id'] as String, + serverUrl: json['serverUrl'] as String, + username: json['username'] as String, + ); +} diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index 40d2812..563c25e 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.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_share.dart'; import '../models/nextcloud_sharee.dart'; +import '../models/saved_account.dart'; +import '../services/account_store.dart'; import '../services/login_flow_service.dart'; import '../services/nextcloud_service.dart'; import '../theme/app_theme.dart'; @@ -58,11 +59,6 @@ class _CachedDirectory { enum MediaProgressBarStyle { classic, wavy, slim, squiggly } 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) static const _prefThemeMode = 'ui_theme_mode'; static const _prefUseDynamicColor = 'ui_use_dynamic_color'; @@ -190,10 +186,33 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { NextcloudService? _service; + // Multi-account state + final AccountStore _accountStore = AccountStore(); + List _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() { WidgetsBinding.instance.addObserver(this); - _restoreSession(); - _loadPreferences(); + _init(); + } + + Future _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 @@ -227,6 +246,12 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { LoginFlowStatus get loginFlowStatus => _loginFlowStatus; Uri? get pendingLoginUrl => _pendingLoginUrl; + List 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; ThemeMode get themeMode => _themeMode; bool get useDynamicColor => _useDynamicColor; @@ -382,13 +407,13 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { Future _restoreSession() async { try { - final server = await _storage.read(key: _keyServerUrl); - final loginName = await _storage.read(key: _keyLoginName); - final appPassword = await _storage.read(key: _keyAppPassword); - - if (server != null && loginName != null && appPassword != null) { - await _applyCredentials(server, loginName, appPassword, persist: false); - } + final id = _activeAccountId; + if (id == null) return; + final account = _accounts.where((a) => a.id == id).firstOrNull; + if (account == null) return; + final password = await _accountStore.readPassword(id); + if (password == null) return; + await _applyCredentialsForAccount(account, password); } catch (e) { debugPrint('[ServerProvider] Session restore failed: $e'); } finally { @@ -425,53 +450,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur; _tapTabToScrollTop = prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop; - _isGridView = prefs.getBool(_prefGridView) ?? _isGridView; - _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; - for (final entry in decoded.entries) { - final value = entry.value as Map; - 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'); - } - } + _applyAccountPrefs(prefs, _activeAccountId); final savedOrderNames = prefs.getStringList(_prefTabOrder); if (savedOrderNames != null) { @@ -531,15 +510,6 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { 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(); notifyListeners(); @@ -548,34 +518,99 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { } } - Future _persistCredentials( - String server, - String loginName, + /// (Re)loads every per-account browsing pref (grid/list view, + /// favorites-only, storage scope, show-hidden, sort, folder sort, cache + /// 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; + for (final entry in decoded.entries) { + final value = entry.value as Map; + 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 _applyCredentialsForAccount( + SavedAccount account, String appPassword, ) async { - await _storage.write(key: _keyServerUrl, value: server); - await _storage.write(key: _keyLoginName, value: loginName); - await _storage.write(key: _keyAppPassword, value: appPassword); - } - - Future _clearPersistedCredentials() async { - await _storage.delete(key: _keyServerUrl); - await _storage.delete(key: _keyLoginName); - await _storage.delete(key: _keyAppPassword); - } - - Future _applyCredentials( - String serverUrl, - String username, - String appPassword, { - bool persist = true, - }) async { + final gen = _sessionGeneration; _isLoading = true; _errorMessage = null; notifyListeners(); - _serverUrl = serverUrl; - _username = username; + _serverUrl = account.serverUrl; + _username = account.username; _password = appPassword; _service = NextcloudService( @@ -586,27 +621,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { try { final success = await _service!.testConnection(); + if (gen != _sessionGeneration) return false; if (success) { _isLoggedIn = true; _currentFolderPath = '/'; _pathStack = ['/']; - if (persist) { - await _persistCredentials(_serverUrl, _username, _password); - } _startCacheRefreshTimerIfNeeded(); await refreshData(); + if (gen != _sessionGeneration) return false; _isLoading = false; notifyListeners(); return true; } } catch (e) { + if (gen != _sessionGeneration) return false; _errorMessage = e.toString().replaceAll('Exception: ', ''); - if (!persist) { - // A previously-saved app password no longer works; drop it. - await _clearPersistedCredentials(); - } + // The stored app password for this account no longer works - drop it + // so it doesn't keep silently failing on every future restore/switch. + await _accountStore.deletePassword(account.id); } + if (gen != _sessionGeneration) return false; _isLoggedIn = false; _service = null; _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 /// URL, opens it in the system browser, then polls until the user /// authorizes and the server hands back a scoped app password. The app - /// never sees the user's real password. - Future startLoginFlow(String serverUrl) async { + /// never sees the user's real password. [addAccount] only marks the + /// 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 startLoginFlow( + String serverUrl, { + bool addAccount = false, + }) async { + _addAccountFlowActive = addAccount; _loginFlowStatus = LoginFlowStatus.initiating; _errorMessage = null; notifyListeners(); @@ -666,11 +709,8 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _pollTimeoutTimer?.cancel(); _loginFlowStatus = LoginFlowStatus.idle; _pendingLoginUrl = null; - await _applyCredentials( - result.serverUrl, - result.loginName, - result.appPassword, - ); + await _completeLoginFlow(result); + _addAccountFlowActive = false; } } on http.ClientException catch (e) { // A single dropped connection (e.g. the network briefly @@ -705,6 +745,7 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { _pollTimer?.cancel(); _pollTimeoutTimer?.cancel(); _pendingLoginUrl = null; + _addAccountFlowActive = false; _loginFlowStatus = errorMessage != null ? LoginFlowStatus.error : LoginFlowStatus.idle; @@ -712,25 +753,192 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { notifyListeners(); } - Future logout() async { - _isLoggedIn = false; - _serverUrl = ''; - _username = ''; - _password = ''; + /// Turns a completed Login Flow v2 result into a saved account (creating + /// it, or refreshing its password if it already existed - e.g. an + /// expired app password re-authenticated) and makes it the active + /// session. Used for both the first/only login and "add account". + Future _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 = []; _quota = null; _activities = []; _allMedia = []; + _isMediaLoading = false; + _mediaErrorMessage = null; _trashItems = []; + _isTrashLoading = false; + _trashErrorMessage = null; _shares = []; + _isSharesLoading = false; + _sharesErrorMessage = null; + _sharesWithMe = false; _recentItems = []; + _isRecentLoading = false; + _recentErrorMessage = null; + _currentFolderPath = '/'; + _pathStack = ['/']; _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 _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 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 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 logout() => removeAccount(_activeAccountId ?? ''); + Future refreshData() async { if (!_isLoggedIn || _service == null) { debugPrint( @@ -738,6 +946,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { ); 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; _errorMessage = null; @@ -748,7 +960,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { ); try { - _items = await _service!.fetchDirectory(_currentFolderPath); + final items = await _service!.fetchDirectory(_currentFolderPath); + if (gen != _sessionGeneration) return; + _items = items; _directoryCache[_currentFolderPath] = _CachedDirectory( _items, DateTime.now(), @@ -773,11 +987,14 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { debugPrint('[ServerProvider] Activity fetch warning: $e'); } } catch (e) { + if (gen != _sessionGeneration) return; debugPrint('[ServerProvider] Error fetching directory: $e'); _errorMessage = e.toString().replaceAll('Exception: ', ''); } finally { - _isLoading = false; - notifyListeners(); + if (gen == _sessionGeneration) { + _isLoading = false; + notifyListeners(); + } } } @@ -789,20 +1006,26 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { /// Loads every image/video across the whole account for the Photos tab. Future fetchAllMedia() async { if (!_isLoggedIn || _service == null) return; + final gen = _sessionGeneration; _isMediaLoading = true; _mediaErrorMessage = null; notifyListeners(); try { - _allMedia = await _service!.fetchAllMedia(); + final media = await _service!.fetchAllMedia(); + if (gen != _sessionGeneration) return; + _allMedia = media; debugPrint('[ServerProvider] Loaded ${_allMedia.length} media items'); } catch (e) { + if (gen != _sessionGeneration) return; debugPrint('[ServerProvider] Error fetching all media: $e'); _mediaErrorMessage = e.toString().replaceAll('Exception: ', ''); } finally { - _isMediaLoading = false; - notifyListeners(); + if (gen == _sessionGeneration) { + _isMediaLoading = false; + notifyListeners(); + } } } @@ -873,26 +1096,42 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { await refreshData(); } + /// Persists a per-account browsing pref under its `acct__`-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) { if (_isGridView == value) return; _isGridView = value; notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefGridView, value)); + _persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value)); } void toggleFavoritesFilterFiles() { _showFavoritesOnlyFiles = !_showFavoritesOnlyFiles; notifyListeners(); - _prefsFuture.then( - (p) => p.setBool(_prefShowFavoritesOnlyFiles, _showFavoritesOnlyFiles), + _persistAccountPref( + _prefShowFavoritesOnlyFiles, + (p, key) => p.setBool(key, _showFavoritesOnlyFiles), ); } void toggleFavoritesFilterPhotos() { _showFavoritesOnlyPhotos = !_showFavoritesOnlyPhotos; notifyListeners(); - _prefsFuture.then( - (p) => p.setBool(_prefShowFavoritesOnlyPhotos, _showFavoritesOnlyPhotos), + _persistAccountPref( + _prefShowFavoritesOnlyPhotos, + (p, key) => p.setBool(key, _showFavoritesOnlyPhotos), ); } @@ -900,20 +1139,27 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { if (_storageScope == scope) return; _storageScope = scope; notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefStorageScope, scope.name)); + _persistAccountPref( + _prefStorageScope, + (p, key) => p.setString(key, scope.name), + ); } void toggleShowHiddenFiles() { _showHiddenFiles = !_showHiddenFiles; notifyListeners(); - _prefsFuture.then((p) => p.setBool(_prefShowHiddenFiles, _showHiddenFiles)); + _persistAccountPref( + _prefShowHiddenFiles, + (p, key) => p.setBool(key, _showHiddenFiles), + ); } void toggleShowHiddenPhotos() { _showHiddenPhotos = !_showHiddenPhotos; notifyListeners(); - _prefsFuture.then( - (p) => p.setBool(_prefShowHiddenPhotos, _showHiddenPhotos), + _persistAccountPref( + _prefShowHiddenPhotos, + (p, key) => p.setBool(key, _showHiddenPhotos), ); } @@ -921,14 +1167,18 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { if (_photosSortField == field) return; _photosSortField = field; notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefSortField, field.name)); + _persistAccountPref( + _prefSortField, + (p, key) => p.setString(key, field.name), + ); } void togglePhotosSortOrder() { _photosSortAscending = !_photosSortAscending; notifyListeners(); - _prefsFuture.then( - (p) => p.setBool(_prefSortAscending, _photosSortAscending), + _persistAccountPref( + _prefSortAscending, + (p, key) => p.setBool(key, _photosSortAscending), ); } @@ -943,8 +1193,9 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { 'ascending': _folderSortAscending[path], }; } - _prefsFuture.then( - (p) => p.setString(_prefFolderSort, jsonEncode(combined)), + _persistAccountPref( + _prefFolderSort, + (p, key) => p.setString(key, jsonEncode(combined)), ); } @@ -1043,7 +1294,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { if (_cachePolicy == policy) return; _cachePolicy = policy; notifyListeners(); - _prefsFuture.then((p) => p.setString(_prefCachePolicy, policy.name)); + _persistAccountPref( + _prefCachePolicy, + (p, key) => p.setString(key, policy.name), + ); _startCacheRefreshTimerIfNeeded(); } @@ -1052,7 +1306,10 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { if (_cacheIntervalMinutes == clamped) return; _cacheIntervalMinutes = clamped; notifyListeners(); - _prefsFuture.then((p) => p.setInt(_prefCacheIntervalMinutes, clamped)); + _persistAccountPref( + _prefCacheIntervalMinutes, + (p, key) => p.setInt(key, clamped), + ); _startCacheRefreshTimerIfNeeded(); } @@ -1095,19 +1352,25 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { Future fetchTrash() async { if (!_isLoggedIn || _service == null) return; + final gen = _sessionGeneration; _isTrashLoading = true; _trashErrorMessage = null; notifyListeners(); try { - _trashItems = await _service!.fetchTrash(); + final trash = await _service!.fetchTrash(); + if (gen != _sessionGeneration) return; + _trashItems = trash; } catch (e) { + if (gen != _sessionGeneration) return; debugPrint('[ServerProvider] Error fetching trash: $e'); _trashErrorMessage = e.toString().replaceAll('Exception: ', ''); } finally { - _isTrashLoading = false; - notifyListeners(); + if (gen == _sessionGeneration) { + _isTrashLoading = false; + notifyListeners(); + } } } @@ -1136,19 +1399,25 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { Future fetchShares() async { if (!_isLoggedIn || _service == null) return; + final gen = _sessionGeneration; _isSharesLoading = true; _sharesErrorMessage = null; notifyListeners(); try { - _shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe); + final shares = await _service!.fetchShares(sharedWithMe: _sharesWithMe); + if (gen != _sessionGeneration) return; + _shares = shares; } catch (e) { + if (gen != _sessionGeneration) return; debugPrint('[ServerProvider] Error fetching shares: $e'); _sharesErrorMessage = e.toString().replaceAll('Exception: ', ''); } finally { - _isSharesLoading = false; - notifyListeners(); + if (gen == _sessionGeneration) { + _isSharesLoading = false; + notifyListeners(); + } } } @@ -1172,19 +1441,25 @@ class ServerProvider extends ChangeNotifier with WidgetsBindingObserver { Future fetchRecent() async { if (!_isLoggedIn || _service == null) return; + final gen = _sessionGeneration; _isRecentLoading = true; _recentErrorMessage = null; notifyListeners(); try { - _recentItems = await _service!.fetchRecentFiles(); + final recent = await _service!.fetchRecentFiles(); + if (gen != _sessionGeneration) return; + _recentItems = recent; } catch (e) { + if (gen != _sessionGeneration) return; debugPrint('[ServerProvider] Error fetching recent files: $e'); _recentErrorMessage = e.toString().replaceAll('Exception: ', ''); } finally { - _isRecentLoading = false; - notifyListeners(); + if (gen == _sessionGeneration) { + _isRecentLoading = false; + notifyListeners(); + } } } diff --git a/lib/services/account_store.dart b/lib/services/account_store.dart new file mode 100644 index 0000000..e65d4e8 --- /dev/null +++ b/lib/services/account_store.dart @@ -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__` 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 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) { + 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 loadAccounts(SharedPreferences prefs) { + final json = prefs.getString(_prefAccountsList); + if (json == null) return []; + try { + final decoded = jsonDecode(json) as List; + return decoded + .map((e) => SavedAccount.fromJson(e as Map)) + .toList(); + } catch (_) { + return []; + } + } + + Future saveAccounts( + SharedPreferences prefs, + List accounts, + ) async { + await prefs.setString( + _prefAccountsList, + jsonEncode(accounts.map((a) => a.toJson()).toList()), + ); + } + + String? loadActiveAccountId(SharedPreferences prefs) => + prefs.getString(_prefActiveAccountId); + + Future saveActiveAccountId(SharedPreferences prefs, String? id) async { + if (id == null) { + await prefs.remove(_prefActiveAccountId); + } else { + await prefs.setString(_prefActiveAccountId, id); + } + } + + Future readPassword(String accountId) => + _storage.read(key: '$_passwordKeyPrefix$accountId'); + + Future writePassword(String accountId, String password) => + _storage.write(key: '$_passwordKeyPrefix$accountId', value: password); + + Future deletePassword(String accountId) => + _storage.delete(key: '$_passwordKeyPrefix$accountId'); +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index fe42c81..2754cce 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -30,6 +30,37 @@ class AppTheme { 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( Color seedColor, { ColorScheme? dynamicScheme, @@ -49,6 +80,7 @@ class AppTheme { scaffoldBackgroundColor: colorScheme.surface, pageTransitionsTheme: _pageTransitionsTheme, sliderTheme: _sliderTheme, + scrollbarTheme: _scrollbarTheme(colorScheme), textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme) .copyWith( headlineMedium: GoogleFonts.inter( @@ -115,6 +147,7 @@ class AppTheme { scaffoldBackgroundColor: colorScheme.surface, pageTransitionsTheme: _pageTransitionsTheme, sliderTheme: _sliderTheme, + scrollbarTheme: _scrollbarTheme(colorScheme), textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme) .copyWith( headlineMedium: GoogleFonts.inter( diff --git a/lib/views/account_view.dart b/lib/views/account_view.dart index 9fbb06b..803bd59 100644 --- a/lib/views/account_view.dart +++ b/lib/views/account_view.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/app_tab.dart'; +import '../models/saved_account.dart'; import '../providers/server_provider.dart'; import '../theme/app_theme.dart'; import '../widgets/frosted_glass_container.dart'; import '../widgets/seek_bar_painter.dart'; +import 'login_view.dart'; class AccountView extends StatelessWidget { const AccountView({super.key}); @@ -177,6 +179,17 @@ class AccountView extends StatelessWidget { ), 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 Text( '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 _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( + 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(); + 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 { const _BottomBarAppearanceCard(); diff --git a/lib/views/activity_view.dart b/lib/views/activity_view.dart index 3705240..dd898d3 100644 --- a/lib/views/activity_view.dart +++ b/lib/views/activity_view.dart @@ -21,7 +21,12 @@ class ActivityView extends StatelessWidget { final List contentSlivers = [ 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( hasScrollBody: false, child: Center( diff --git a/lib/views/login_view.dart b/lib/views/login_view.dart index 5583a23..b917b02 100644 --- a/lib/views/login_view.dart +++ b/lib/views/login_view.dart @@ -3,7 +3,13 @@ import 'package:provider/provider.dart'; import '../providers/server_provider.dart'; 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 State createState() => _LoginViewState(); @@ -12,6 +18,16 @@ class LoginView extends StatefulWidget { class _LoginViewState extends State { final _formKey = GlobalKey(); final _urlController = TextEditingController(); + String? _originalActiveAccountId; + bool _popped = false; + + @override + void initState() { + super.initState(); + if (widget.isAddingAccount) { + _originalActiveAccountId = context.read().activeAccountId; + } + } @override void dispose() { @@ -22,7 +38,10 @@ class _LoginViewState extends State { void _handleContinue() { if (!_formKey.currentState!.validate()) return; FocusScope.of(context).unfocus(); - context.read().startLoginFlow(_urlController.text.trim()); + context.read().startLoginFlow( + _urlController.text.trim(), + addAccount: widget.isAddingAccount, + ); } @override @@ -35,7 +54,22 @@ class _LoginViewState extends State { provider.loginFlowStatus == LoginFlowStatus.awaitingBrowser; 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( child: Center( child: SingleChildScrollView( @@ -65,6 +99,21 @@ class _LoginViewState extends State { ), ), ); + + 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, + ); } } diff --git a/lib/widgets/profile_avatar_button.dart b/lib/widgets/profile_avatar_button.dart index e802e1d..d57585e 100644 --- a/lib/widgets/profile_avatar_button.dart +++ b/lib/widgets/profile_avatar_button.dart @@ -1,14 +1,37 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../providers/server_provider.dart'; import '../views/account_view.dart'; /// Top-bar avatar button that opens the account screen. Replaces the old /// "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 { const ProfileAvatarButton({super.key}); + void _handleVerticalSwipe(BuildContext context, DragEndDetails details) { + final velocity = details.primaryVelocity ?? 0; + if (velocity.abs() < 250) return; + + final provider = context.read(); + 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 Widget build(BuildContext context) { final provider = context.watch(); @@ -19,23 +42,26 @@ class ProfileAvatarButton extends StatelessWidget { return Padding( padding: const EdgeInsets.only(right: 8), - child: IconButton( - tooltip: 'Settings', - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const AccountView()), - ); - }, - icon: CircleAvatar( - radius: 17, - backgroundColor: colorScheme.primary, - child: Text( - initial, - style: TextStyle( - color: colorScheme.onPrimary, - fontWeight: FontWeight.bold, - fontSize: 14, + child: GestureDetector( + onVerticalDragEnd: (details) => _handleVerticalSwipe(context, details), + child: IconButton( + tooltip: 'Settings', + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AccountView()), + ); + }, + icon: CircleAvatar( + radius: 17, + backgroundColor: colorScheme.primary, + child: Text( + initial, + style: TextStyle( + color: colorScheme.onPrimary, + fontWeight: FontWeight.bold, + fontSize: 14, + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 054d8f1..e4e6500 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -88,6 +88,7 @@ flutter: assets: - assets/icon/app_icon.png + - assets/icon/app_icon_monochrome.png # To add assets to your application, add an assets section, like this: # assets: