Files
noo/.claude/context/architecture.md
T
ayushyaandClaude Sonnet 5 5dea9038f7 Make the shell top bar hide/reveal with scroll, per tab
Each regular tab (Files/Photos/Favorites/Shares/Recent/Activity/Trash)
now plants its own AppTopBar as the first sliver in its own
CustomScrollView, via a new topBarSliver() helper (tab_state_slivers.dart)
wrapping the framework's SliverFloatingHeader, instead of one shared
instance living in Scaffold.appBar.

This gives each tab's top bar Material's native floating-app-bar
behavior - tied to that tab's own ScrollController - scrolling away as
the list scrolls down and reappearing the moment the drag reverses,
not only once scrolled all the way back to the top, which a single
fixed Scaffold.appBar (or the "put it in a SliverToBoxAdapter" escape
hatch NooTopBar's doc comment already mentioned) can't do. It sits
above each tab's own pinned in-content sticky header (the sort/filter
row, or the selection bar that replaces it), which keeps working
unchanged underneath it.

main.dart builds a separate, correctly-labelled AppTopBar per tab
(rather than one for "whichever tab is selected") and passes it through
buildAppTabView's new optional topBar parameter; it's null on desktop
(which keeps its own NooToolbar) and while picking, matching the old
Scaffold.appBar: pickRequest == null guard.

SliverFloatingHeader sizes itself from the bar's own natural layout
(like SliverToBoxAdapter) rather than a fixed extent declared up front,
so NooTopBar's own internal SafeArea keeps handling the status-bar inset
correctly with no extra height math needed on this side.

Adds a widget test (test/widgets/tabs/tab_state_slivers_test.dart)
driving a raw gesture mid-drag to verify the reveal-follows-finger
behavior, since a single tester.drag() only pumps after the gesture -
including release - has already finished, which doesn't exercise the
live per-frame reveal this change depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:28:36 -04:00

25 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/           # per-domain ChangeNotifiers (SessionController,
                       # FilesController, PhotosController, ...) plus
                       # ItemOperations, a plain cross-domain coordinator
  services/             # network/IO: NextcloudService, LoginFlowService,
                         # AccountStore, AppLockService
  theme/                 # AppTheme (Material 3 ThemeData, pre-rework
                         # screens) + design_tokens.dart (NooColors/NooText/
                         # NooSpace/NooRadii/NooSizes/NooMotion - see
                         # styling.md's "Noo design-system components")
  views/                  # one screen each (FilesView, PhotosView, TrashView,
                           # SharesView, RecentView, ActivityView, SearchView,
                           # AccountView, LoginView, FileViewerScreen,
                           # ShareUploadView, LockScreenView)
  widgets/                 # reusable pieces shared across views
    noo/                    # the Noo design-system component kit - see
                            # styling.md's catalog
    details/                # the file-details bottom sheet and its tabs
    shell/                  # pieces shared by the mobile/desktop app shell
    settings/               # Settings' section widgets
    tabs/                   # loading/error/empty slivers + grouping helpers
                            # shared by Recent/Activity/Trash/Shares
    files/                  # pieces split out of FilesView (e.g. the
                            # breadcrumb row)

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 whichever controller(s) it needs via context.watch/context.read. Most tabs, the app shell, and Settings are now built from widgets/noo/ rather than raw Material widgets - see styling.md's "Noo design-system components" for the token/component catalog and the current rebuild status.

State management

State used to live in one 2300+ line ServerProvider god object. It's now split into ten focused ChangeNotifiers plus one plain coordinator, all registered in main()'s MultiProvider in dependency order (later providers read earlier ones via context.read in their create callback — safe since none of these providers are ever recreated for the app's lifetime; account switching is internal state on SessionController, not a new provider instance):

  • SessionController — the foundation everything else depends on. Owns multi-account state (the saved-accounts list, which one is active, sessionGeneration — see below), auth/login-flow state (isLoggedIn, isRestoringSession, loginFlowStatus), the active NextcloudService instance, and login lock (loginLockEnabled/lockAccountSwitching/lockHiddenFiles/ needsUnlock/passGate). Exposes addAccountClearedListener/ addAccountActivatedListener (plain List<VoidCallback>) so sibling controllers — constructed after SessionController and unable to hold a forward reference to it — can react to login/logout/account-switch without a circular dependency.
  • SettingsController — global UI prefs independent of login state: theme mode/seed color/dynamic color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab order/visibility/default (requestedTab/requestTab/ consumeRequestedTab), swipe actions.
  • FilesController — the Files tab: items/currentFolderPath/pathStack, the in-memory _directoryCache (see CachePolicy), per-account display prefs (grid/list, hidden files, storage scope, sort field/order, per-folder sort map), and the shared applyCommonFilters/applyFilesDisplayPrefs helpers Photos/Favorites reuse rather than duplicating the same filter/sort logic. refreshData refreshes in place when a listing is already on screen (pull-to-refresh, periodic refresh, post-mutation): no spinner, the list is swapped once the server answers, and a failed refresh keeps the old list. The spinner/error page only appear when there is nothing to show (first load, _navigateTo clears the outgoing folder first, retry after an error). Keep that invariant - blanking the list to a spinner made refreshes look like files vanishing and reappearing.
  • PhotosController / FavoritesController — each depends on FilesController for the shared storage-scope toggle and display-prefs helpers, but owns its own account-wide item list and independent favorites-only/hidden/sort state.
  • TrashController / SharesController / RecentController — one per remaining tab, each just items/isLoading/errorMessage plus that tab's own fetch/mutate methods. All three (plus Photos/Favorites) share one shape: capture session.sessionGeneration at fetch entry, set loading/clear error, fetch, check the generation before writing the result, notifyListeners().
  • SyncStatusController — device-sync settings and live status (syncStatusFor(item), syncHeaderStatus, conflicts), subscribed to SyncService's status stream directly rather than living inside the same object as the item lists it decorates. Depends on FilesController (declared before it in main()) because synced files refresh by the Files Cache rule - it owns the foreground timer/resume/pull triggers and background scheduling; see server.md's "Keeping synced files current automatically".
  • PickController — "being picked by another app" state (pickRequest/isPicking/confirmPick/ itemMatchesPickFilter) — see server.md.
  • FolderBrowser — the small interface (pathStack/currentFolderPath/items/isLoading/ errorMessage/navigation/reload) FilesView needs from whatever supplies its folder. FilesController (server) and OfflineController (local mirror) both implement it, which is what lets the Offline tab be FilesView(offline: true) - literally the Files tab over the device-sync mirror, not a second view. offline only swaps the data source, reads images from the local file (localFileFor) instead of a server preview, and turns off what needs the server: selection and bulk actions, swipe actions, per-item sync status icons, "+" (replaced by "Manage synced folders"), pick mode. The two FilesViews are keyed in buildAppTabView so the IndexedStack never hands one's State to the other.
  • OfflineController — the Files tab's folder listing read from local storage instead of the server. Owns no display prefs: items runs the listing through FilesController.applyFilesDisplayPrefs (hidden files, files/folders filter, per-folder sort keyed by the same remote path, minus the cloud/external scope) and FilesView reads grid/list from FilesController, so both tabs share one set of controls (FilesControlsRow). FilesController restores those prefs on the account-ready event too (memoized _restoreDisplayPrefs) so they apply offline. Refreshed (see server.md's "Device sync" section) automatically whenever a sync pass finishes. Listens for SessionController.addAccountReadyListener, not addAccountActivatedListener - its work is local-only, so it's safe to run even on a provisional/offline login (see ConnectivityController below). Registered with lazy: false in main.dart (as is SyncStatusController) - the ready event fires once at startup, before any widget reads a lazily-created provider, so a lazy one would register its listener too late and never load its persisted state.
  • ConnectivityController — wraps connectivity_plus, exposing just isOffline. The one thing every network-touching controller (indirectly, via SessionController) and MainShellView consult before making a request or deciding what to show - see server.md's "Working offline" section.
  • ItemOperations — not a ChangeNotifier; a plain, const-constructible class holding direct references to SessionController/FilesController/PhotosController/ FavoritesController. The one place that knows how a mutation (delete/rename/move/copy/favorite-toggle/create-folder) ripples across those three tabs' independent item lists — deliberately direct references rather than a generic event bus, so a caller can await a mutation and know every affected list has already been reconciled by the time it returns, the same guarantee the old god object gave for free. Also home to the file-details sheet's per-item pass-throughs (shares/activity/versions/restore) — stateless, so they don't need a controller of their own.

Guardrail - the "empty list on first load" failure class. This bug has recurred more than once, always the same shape: a tab's list is empty after login even though the account is fine, because the controller behind it never ran its initial fetch. The cause is a race, not a network bug: addAccountActivatedListener/addAccountReadyListener fire once, at the moment login is verified/ready, to whichever listeners are registered at that exact instant - and most per-domain controllers (FilesController, PhotosController, FavoritesController, TrashController, SharesController, RecentController) are lazy ChangeNotifierProviders, only constructed (and so only registering their listener) whenever something first reads them. If that first read happens to land after the one-shot event already fired - e.g. ConnectivityController misreporting offline for its first couple of seconds after a cold Android start (see its own doc comment) collapses main.dart's bottom nav to just the Offline tab, delaying construction of every other tab's controller until connectivity corrects itself and login has already finished verifying - that controller's listener registers too late and its initial fetch simply never happens. SessionController.addAccountActivatedListener/ addAccountReadyListener now close this at the root: registering either one calls back immediately if the account is already in the state being subscribed to, not just on the next fresh event, so a late-registering controller always gets its initial fetch regardless of when its lazy Provider happens to be built. Some views (FilesView/PhotosView, not FavoritesView - see FavoritesController's own doc comment) additionally carry a build-time fallback (_requestedInitialLoad et al.: reload if items are empty and not loading, once, after first build) predating this fix; they're now redundant but harmless, and not worth touching for cleanup alone. Guardrail for new code: any new network-fetching controller that needs a one-time fetch on login must register through addAccountActivatedListener/addAccountReadyListener in its constructor and rely on their catch-up behavior - don't reach for a per-view build-time "fetch if empty" fallback as the primary mechanism, since that pattern is exactly what let this bug keep recurring silently (three near-duplicate, slightly-diverging implementations, none of them fixing the actual race).

sessionGeneration (on SessionController, read by every other controller) is 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 — see server.md for the full account-switch/storage story.

New global state belongs on SettingsController as a private field + getter + a method that mutates it, calls notifyListeners(), and persists via its own prefs future. New per-account state follows the same shape on whichever controller owns that domain, but persists through _persistAccountPref (namespaces the pref key under acct_<activeAccountId>_... via AccountStore.accountPrefKey) and must be reset/reloaded on SessionController's account-activated listener 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 controller methods directly, which call NextcloudService/LoginFlowService/AccountStore/ AppLockService.

Navigation / screen flow

main.dart's NextcloudApp picks the app's home screen from SessionController state, no named routes:

  • session.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 !session.isLoggedIn → LoginView (server-address entry + Login 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 session.needsUnlock → LockScreenView (login lock — see above)
  • else MainShellView

MainShellView is an IndexedStack over up to 8 tabs — Files, Photos, Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable (order, visibility up to maxVisibleTabs, default tab) via AppTab/SettingsController and rendered through buildAppTabView (widgets/app_tab_view_builder.dart). maxVisibleTabs (5) is less than the total tab count, and SettingsController._enforceMaxVisibleTabs already auto-hides overflow on load (fresh install, or - as when Favorites was added - an existing saved tab order from before a new tab existed), so adding a tab to the AppTab enum needs no extra migration. Only the (up to) 5 pinned tabs (settings.visibleTabs) become bottom-nav/sidebar destinations; the rest sit in the "More" section of the drawer (mobile) or sidebar (desktop) - see below. 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. FilesView's own "+" → "Upload File" (_pickAndUploadFile) reaches the exact same ShareUploadView screen through the same SharedFileRef-based path (wrapping file_picker's result Uris instead of a share intent's) rather than a separate in-app-only upload, so both entry points get the same destination picker and the same durable background-service upload. It similarly owns the pick-intent listener (PickIntentService - see server.md for the full "being picked by another app" story) that feeds PickController.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. The same override mechanism restricts the visible tabs to just Offline while ConnectivityController.isOffline - see server.md's "Working offline" section for the full story, including why session restore itself has to avoid making a network request in that case. The drawer/sidebar's "More" section is empty in both cases (picking, offline) for the same reason - there's nothing useful in it to switch to - and the mobile drawer doesn't open at all while picking (Scaffold.drawer is null). MainShellView also fires a one-time notification-permission prompt on its first mount (_maybeRequestNotificationPermission, gated by a plain shared_preferences flag so it only ever asks once, not on every launch): a plain-language dialog (showNooDialog, two NooButtons) explaining why Noo wants it (upload/download progress notifications - see ShareUploadService.kt/DownloadService.kt) before the OS's own permission_handler-driven Permission.notification.request(), since the bare system prompt gives no context on its own.

MainShellView builds its chrome from the Noo nav kit (widgets/noo/nav/) and switches between two layouts on NooLayout.isDesktop:

  • Mobile: AppTopBar (widgets/app_top_bar.dart) wraps NooTopBar for every tab (previously only Files had shell-level top chrome, with the rest building their own via SyncedHeaderScaffold) - iOS gets a large title, an inline search field, a plus action on Files only (no other tab has a create/upload flow), and the account avatar; Android gets a compact title row with search/avatar actions, relying on an extended NooFab ("Upload", Files/Photos only) instead of a top-bar icon for upload. Rather than one shared instance in Scaffold.appBar, MainShellView builds a separate AppTopBar per tab (labelled for that tab) and each tab plants its own as the first sliver in its own CustomScrollView (topBarSliver in widgets/tabs/tab_state_slivers.dart, wrapping it in the framework's SliverFloatingHeader) instead of passing it to Scaffold.appBar - see that file's doc comment for why (Material's native "floating" app bar behavior, tied to that tab's own ScrollController: scrolls away as the list scrolls down and reappears the moment the drag reverses, not only once scrolled back to the top). topBar is null (no top bar rendered) on desktop and while picking, matching Scaffold.appBar's old pickRequest == null guard - see buildAppTabView's doc comment. It sits above each tab's own pinned in-content header (the sort/filter controls row, or Files/Photos' selection bar - see below); the two float/scroll independently. BottomNavBar (widgets/bottom_nav_bar.dart) adapts the pinned AppTabs onto NooBottomBar. AppDrawer (widgets/app_drawer.dart) builds a NooDrawer: account block, storage meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs" link (opens Settings - there's no in-page anchor to scroll to its Tabs section yet).
  • Desktop: a NooSidebar (account card, pinned tabs, divider, remaining tabs, storage meter, Settings) sits beside a NooToolbar (tab title, search, an "Upload" action on Files/Photos) over the same IndexedStack, both built inline in main.dart rather than as separate widgets.

Both layouts share one detail: tapping a hidden ("More") tab calls SettingsController.requestTab - the same one-shot request SearchView/FavoritesView/ShareUploadView already use to jump the shell to a tab from outside it - instead of pushing that tab as its own screen. MainShellView then shows it as the active tab (still just an entry in the IndexedStack) with no bottom-bar/sidebar destination highlighted, since it isn't one of the pinned five, until the user taps a pinned or another "More" tab. widgets/shell/shell_common.dart holds the pieces both layouts share: account/storage formatting, openSettings/ openSearch, showAccountSwitcher (the saved-accounts list behind the drawer's chevron and the sidebar's account card - a sheet on mobile, a dialog on desktop), ShellAvatarButton and ShellSearchLauncher.

All eight tabs used to 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. Now that every tab is rebuilt on Noo, each renders its own content directly in a plain RefreshIndicator + CustomScrollView instead (ColoredBox(colors.bg) background, no shared header widget); device-sync status shows per-row (NooFileRow/NooFileTableRow's NooStatusIcons) or in the Offline tab's NooSummaryCard, not a shared pinned chip. SyncedHeaderScaffold is now unused - the last two screens on it, ShareUploadView (the share-to-upload destination picker, pushed rather than a tab - see below) and MoveCopyDestinationPicker, are rebuilt too: both are a Scaffold with a NooTopBar+NooTopBarBack (mobile) / NooToolbar (desktop) top bar, a noo-styled Breadcrumbs row + folder list (NooFileRow mobile, NooFileTableRow desktop - both pickers only ever browse folders, so the old dimmed-but-visible file rows are gone; a RefreshIndicator+CustomScrollView) for a body, and a colors.surface bottom bar with a top line (no more rounded-top elevated Material sheet) holding the primary CTA. ShareUploadView is pushed from outside MainShellView (a cold share-intent launch, or Files' "+" → "Upload file"), so it builds its own top bar rather than relying on the shell's - its actions are [ShellAvatarButton()] only (no MoreTabsButton: there's nowhere useful for it to go mid-upload, since jumping to another tab would abandon the destination picker); ShellAvatarButton is the same tap-to-Settings/swipe-to-cycle-accounts widget the shell itself uses, and a straight replacement for the old ProfileAvatarButton (now unused anywhere, since this was its last call site). Its bottom bar holds the uploading-file-name summary (single line, auto-scrolling via MarqueeTitle if it doesn't fit) above the "Upload to {folder}" CTA. 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.

The Move/Copy destination picker (MoveCopyDestinationPicker, pushed from Files/Photos' selection toolbar - see server.md for the backend side) visually mirrors ShareUploadView's browser the same way, but is a deliberately different case for state: it does not reuse FilesController's shared currentFolderPath/pathStack/items, and owns its own local navigation state instead, fetching through the stateless FilesController.fetchFolderListing/applyFilesDisplayPrefs pair. ShareUploadView can get away with hijacking the shared state because it always resets to root on entry and pops all the way to the app's root route on completion - fine for a cold share-intent launch with no prior browsing session to preserve. The Move/Copy picker is pushed while the user is actively browsing a specific Files-tab folder, so reusing the shared state would strand that folder's pathStack under it; its local state means popping back always lands the user exactly where they were, untouched. Unlike ShareUploadView, it keeps MoreTabsButton in its top bar: that button pushes a hidden tab as its own stacked screen rather than jumping the shell there, so it doesn't abandon this picker the way jumping tabs would. Its bottom bar shows a "Moving/Copying N item(s)" summary line (replaced by a danger-colored warning when the current folder is an invalid destination) above a "Move here"/"Copy here" CTA - the per-item conflict follow-up, if any, is MoveCopyConflictSheet. SearchView, AccountView (Settings), the file-details sheet (DetailsSheet), and the share sheet (ShareSheet) are pushed on top via Navigator/showNooSheet/showNooDialog rather than being tabs (the two per-item sheets pick between the mobile sheet and the desktop dialog via NooLayout.isDesktop, same as every other overlay in the app; showGradualBottomSheet, the custom drag-to-resize sheet they used before, has no remaining callers). On mobile, ShellAvatarButton (widgets/shell/shell_common.dart, shown in AppTopBar's trailing actions on every tab, and reused directly by ShareUploadView) opens Settings on tap and cycles between saved accounts on a vertical swipe. Desktop has no avatar in the toolbar; NooSidebarAccount's account card opens the full switcher instead (showAccountSwitcher, the same one behind the mobile drawer's chevron).

Files and Photos (the two tabs with multi-select) render their selection bar as a pinned: true sliver at the top of their own CustomScrollView, swapped in for the controls row/type-chips row while _isSelecting - selecting reads as replacing that row in place, not adding a strip beneath it. (An earlier version of this routed the selection bar through SyncedHeaderScaffold's selectionBar param; that's gone along with the scaffold itself in these two tabs.)