- Let Noo be picked as a source (Files + Photos) by other apps' native file/photo pickers via Android's GET_CONTENT intent, mirroring the existing "Share to Noo" plumbing: MainActivity.kt/PickIntentService/ PickRequest, ServerProvider.pickRequest/confirmPick/cancelPick, and pick-mode tap handling in FilesView/PhotosView. While picking, the bottom nav and MoreTabsButton are restricted to Files/Photos only. - Fix two account bugs: testConnection() failures no longer delete a still-valid stored app password (only an actual 401 does), and switchAccount() now retries a nominally-active-but-logged-out account instead of no-op'ing - both were silently locking users out of "Continue as" with no way back in except removing and re-adding the account (now exposed via login_view.dart's inline remove button). - Fix the login WebView: clear cookies before every load so "Add Account" can't silently reuse an existing session, and push it as a non-animated route to avoid a known WebView-as-PlatformView black-screen- on-pop issue under Impeller. - Redesign the Files/Photos selection toolbar to take over the top app bar entirely (SyncedHeaderScaffold.selectionBar) instead of sitting as a second bar under the controls row, with a horizontally-scrollable, uniformly-colored actions row and the same one-shot scroll-hint nudge the controls row uses. - Redesign ShareUploadView to share the same SyncedHeaderScaffold chrome as every tab, with the uploading-file summary (marqueed via the new shared MarqueeTitle widget) and upload button grouped into one rounded/elevated bottom sheet. - Restyle ShareSheet to open on the same DetailsHeader every other per-item sheet uses, and fix a couple of small design-language drifts (hardcoded text style, a non-rounded icon). - Settings' account card: icon buttons with tooltips instead of text buttons for refresh/logout/remove, divider removed.
8.9 KiB
Architecture
Layout
lib/
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,
# AccountStore, AppLockService
theme/ # AppTheme (Material 3 ThemeData)
views/ # one screen each (FilesView, PhotosView, TrashView,
# SharesView, RecentView, ActivityView, SearchView,
# AccountView, LoginView, FileViewerScreen,
# ShareUploadView, LockScreenView)
widgets/ # reusable pieces shared across views
details/ # the file-details bottom sheet and its tabs
views/ files are screens routed to directly (a tab, or pushed via
Navigator). widgets/ files are building blocks used by more than one
view (or complex enough to warrant their own file) — nothing in widgets/
owns app state itself; it reads it from the ServerProvider passed down or
read via context.watch/context.read.
State management
There is exactly one ChangeNotifier: ServerProvider
(~1500 lines). It is created once in main() and provided at the root with
provider's ChangeNotifierProvider. It owns:
- Multi-account state: the list of saved accounts (
accounts), which one is active (activeAccountId/activeAccount), and a_sessionGenerationcounter 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. Seeserver.mdfor the full account-switch/storage story. - auth/session state for whichever account is active (
isLoggedIn,isRestoringSession, login-flow status) - the active
NextcloudServiceinstance (null until logged in) - 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_directoryCachekeyed by folder path (seeCachePolicy) - 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, login lock — see below) and per-account (grid/list view,
favorites-only, show-hidden, storage scope, Photos sort, Files'
per-folder sort map, cache policy) — see
server.mdfor exactly which is which and why - Login lock (
loginLockEnabled/lockAccountSwitching/lockHiddenFiles/needsUnlock): an app-wide PIN/biometric gate viaAppLockService(a thin wrapper overlocal_auth— this app never stores or hashes a PIN itself, it delegates entirely to whatever credential the OS already has configured).needsUnlockisloginLockEnabled && !_isUnlocked, where_isUnlockedis transient (never persisted) and reset tofalseon every backgrounding (didChangeAppLifecycleState,AppLifecycleState.paused) so the lock has real value rather than only firing once per cold start.switchAccount/cycleToNextAccount/cycleToPreviousAccountand enabling (not disabling)showHiddenFiles/showHiddenPhotoseach call the shared_passGatehelper, which no-ops unless bothloginLockEnabledand the relevant per-feature toggle are on.
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 underacct_<activeAccountId>_...viaAccountStore.accountPrefKey) and must be reset/reloaded in_applyAccountPrefsso it's correct after a switch. Screen-local state (e.g. aTextEditingController, an expanded/collapsed flag) stays in that view's ownStateclass — seestandards.mdfor the split.
There's no separate repository/data layer: views call ServerProvider
methods directly, which call NextcloudService/LoginFlowService/
AccountStore/AppLockService.
Navigation / screen flow
main.dart's NextcloudApp picks the app's home screen from provider
state, no named routes:
provider.isRestoringSession→_SplashView(monochrome app icon, tinted viaColorFilteredto the theme'sonSurfaceso it works in both light/dark, plus a small spinner, whileflutter_secure_storage/shared_preferencesare read on startup)- else
!provider.isLoggedIn→LoginView(server-address entry + Login Flow v2) —isLoggedInis only ever false here or after the last saved account is removed; switching between multiple saved accounts never routes through this screen (seeserver.md) - else
provider.needsUnlock→LockScreenView(login lock — see above) - else
MainShellView
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 (ShareIntentService, backed by
hand-rolled native handling in MainActivity.kt - see server.md for why
this isn't the receive_sharing_intent plugin): both getInitialShare()
(cold start via another app's "Share to...") and onNewShare (already
running) push ShareUploadView. It similarly owns the pick-intent listener
(PickIntentService - see server.md for the full "being picked by
another app" story) that feeds ServerProvider.pickRequest; while
isPicking, the visible tab list is overridden to just Files and Photos
regardless of the user's own hidden/reordered tab settings, since those
are the only two views that know how to handle a picking-mode tap and the
only two that make sense as external "choose a file" sources.
All six tabs, plus ShareUploadView (the share-to-upload destination
picker, pushed rather than a tab - see below), share
SyncedHeaderScaffold — a
CustomScrollView with a pull-down "sync status" header (Google
Photos-style) and a classic Material refresh spinner shown during a
pull-triggered sync. ShareUploadView mirrors the Files tab's own
controls-row/breadcrumbs sticky header almost exactly, so arriving via
another app's "Share to..." sheet still lands on the same top chrome
instead of a plain AppBar - its actions are the exact same
[MoreTabsButton(), ProfileAvatarButton()] every non-Files tab uses (kept
uniform deliberately; backing out is the system back gesture/button, not a
bespoke close icon in the app bar). Its bottom action - "Upload to
{folder}" - and the uploading-file-name summary above it (single line,
auto-scrolling via MarqueeTitle if it doesn't fit) live together in one
rounded-top, elevated Material bar as bottomNavigationBar, reading as a
sheet peeking up from the bottom edge rather than a plain flat bar.
MarqueeTitle (package:marquee) is
shared with FileViewerScreen's title - falls back to a plain ellipsized
Text when the content already fits, so short text never marquees.
SearchView, AccountView (Settings), the file-details sheet, and the
share sheet 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.
Files and Photos (the two tabs with multi-select) pass their selection
toolbar into SyncedHeaderScaffold's selectionBar param rather than
rendering it as a second sliver app bar inside their own content: while
non-null, it fully takes over the pinned top bar in place of the
sync-status chip/actions/pull-to-reveal quota panel, so selecting reads
as replacing the whole top chrome rather than adding a strip beneath it.