Files
noo/.claude/context/architecture.md
T
ayushyaandClaude Sonnet 5 ff5f04e9a1
Build APK / build (push) Successful in 5m20s
Dedupe native transfer/notification code and split ServerProvider into focused controllers
Consolidates duplicated GET/PUT/notification-channel logic across
DownloadService/ShareUploadService/SyncEngine into shared Kotlin helpers,
gives upload/download real batch queueing instead of dropping a second
concurrent batch, and dedupes repeated Dart channel-argument boilerplate.

Replaces the 2300+ line ServerProvider god object with ten focused
ChangeNotifiers (SessionController, SettingsController, FilesController,
PhotosController, FavoritesController, TrashController, SharesController,
RecentController, SyncStatusController, PickController) plus ItemOperations,
a plain coordinator for cross-domain item mutations - fixing the coupling
where device-sync status, per-tab data, and global UI prefs all lived in
one object. Updates every view/widget call site accordingly and refreshes
the architecture/server/standards/styling docs to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:04:12 -04:00

13 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)
  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 whichever controller(s) it needs via context.watch/context.read.

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.
  • 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.
  • PickController — "being picked by another app" state (pickRequest/isPicking/confirmPick/ itemMatchesPickFilter) — see server.md.
  • 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.

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 a bottom-nav IndexedStack over up to 7 tabs — Files, Photos, Favorites, Activity, Trash, Shares, Recent — 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. 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. 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 AlertDialog 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.

FloatingBottomNavBar (widgets/floating_bottom_bar.dart) wraps its pill's item row in a horizontal SingleChildScrollView rather than a plain Row, so a wide selected-item label plus several icon-only tabs scrolls instead of overflowing on narrower screens.

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 [ProfileAvatarButton()] only (no MoreTabsButton: there's nowhere useful for it to go mid-upload, since jumping to another tab would abandon the destination picker); 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.

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. 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.