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>
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 activeNextcloudServiceinstance, and login lock (loginLockEnabled/lockAccountSwitching/lockHiddenFiles/needsUnlock/passGate). ExposesaddAccountClearedListener/addAccountActivatedListener(plainList<VoidCallback>) so sibling controllers — constructed afterSessionControllerand 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(seeCachePolicy), per-account display prefs (grid/list, hidden files, storage scope, sort field/order, per-folder sort map), and the sharedapplyCommonFilters/applyFilesDisplayPrefshelpers Photos/Favorites reuse rather than duplicating the same filter/sort logic.refreshDatarefreshes 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,_navigateToclears 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 onFilesControllerfor 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 justitems/isLoading/errorMessageplus that tab's own fetch/mutate methods. All three (plus Photos/Favorites) share one shape: capturesession.sessionGenerationat 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 toSyncService's status stream directly rather than living inside the same object as the item lists it decorates. Depends onFilesController(declared before it inmain()) because synced files refresh by the Files Cache rule - it owns the foreground timer/resume/pull triggers and background scheduling; seeserver.md's "Keeping synced files current automatically".PickController— "being picked by another app" state (pickRequest/isPicking/confirmPick/itemMatchesPickFilter) — seeserver.md.FolderBrowser— the small interface (pathStack/currentFolderPath/items/isLoading/errorMessage/navigation/reload)FilesViewneeds from whatever supplies its folder.FilesController(server) andOfflineController(local mirror) both implement it, which is what lets the Offline tab beFilesView(offline: true)- literally the Files tab over the device-sync mirror, not a second view.offlineonly 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 twoFilesViews are keyed inbuildAppTabViewso theIndexedStacknever 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:itemsruns the listing throughFilesController.applyFilesDisplayPrefs(hidden files, files/folders filter, per-folder sort keyed by the same remote path, minus the cloud/external scope) andFilesViewreads grid/list fromFilesController, so both tabs share one set of controls (FilesControlsRow).FilesControllerrestores those prefs on the account-ready event too (memoized_restoreDisplayPrefs) so they apply offline. Refreshed (seeserver.md's "Device sync" section) automatically whenever a sync pass finishes. Listens forSessionController.addAccountReadyListener, notaddAccountActivatedListener- its work is local-only, so it's safe to run even on a provisional/offline login (seeConnectivityControllerbelow). Registered withlazy: falseinmain.dart(as isSyncStatusController) - 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— wrapsconnectivity_plus, exposing justisOffline. The one thing every network-touching controller (indirectly, viaSessionController) andMainShellViewconsult before making a request or deciding what to show - seeserver.md's "Working offline" section.ItemOperations— not aChangeNotifier; a plain,const-constructible class holding direct references toSessionController/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 canawaita 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 viaColorFilteredto the theme'sonSurfaceso it works in both light/dark, plus a small spinner, whileflutter_secure_storage/shared_preferencesare read on startup)- else
!session.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
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) wrapsNooTopBarfor every tab (previously only Files had shell-level top chrome, with the rest building their own viaSyncedHeaderScaffold) - iOS gets a large title, an inline search field, aplusaction on Files only (no other tab has a create/upload flow), and the account avatar; Android gets a compact title row withsearch/avatar actions, relying on an extendedNooFab("Upload", Files/Photos only) instead of a top-bar icon for upload. Rather than one shared instance inScaffold.appBar,MainShellViewbuilds a separateAppTopBarper tab (labelled for that tab) and each tab plants its own as the first sliver in its ownCustomScrollView(topBarSliverinwidgets/tabs/tab_state_slivers.dart, wrapping it in the framework'sSliverFloatingHeader) instead of passing it toScaffold.appBar- see that file's doc comment for why (Material's native "floating" app bar behavior, tied to that tab's ownScrollController: scrolls away as the list scrolls down and reappears the moment the drag reverses, not only once scrolled back to the top).topBaris null (no top bar rendered) on desktop and while picking, matchingScaffold.appBar's oldpickRequest == nullguard - seebuildAppTabView'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 pinnedAppTabs ontoNooBottomBar.AppDrawer(widgets/app_drawer.dart) builds aNooDrawer: 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 aNooToolbar(tab title, search, an "Upload" action on Files/Photos) over the sameIndexedStack, both built inline inmain.dartrather 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.)