addAccountActivatedListener/addAccountReadyListener fired once, to whichever listeners were registered at that instant. Most per-tab controllers are lazy providers, only constructed (and so only registering) whenever something first reads them - if that happened after the one-shot event already fired (e.g. a cold-start connectivity misdetection delaying a tab's construction until after login verified), that controller's initial fetch never ran, leaving its list permanently empty. Both listeners now call back immediately on registration if the account is already in the state being subscribed to, closing the race regardless of construction timing. Documents the failure class and the guardrail for future controllers in architecture.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
390 lines
24 KiB
Markdown
390 lines
24 KiB
Markdown
# 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 `ChangeNotifier`s 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`](../../lib/providers/session_controller.dart) — 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`](../../lib/providers/settings_controller.dart) —
|
|
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`](../../lib/providers/files_controller.dart) — 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`](../../lib/providers/photos_controller.dart) /
|
|
[`FavoritesController`](../../lib/providers/favorites_controller.dart) —
|
|
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`](../../lib/providers/trash_controller.dart) /
|
|
[`SharesController`](../../lib/providers/shares_controller.dart) /
|
|
[`RecentController`](../../lib/providers/recent_controller.dart) — 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`](../../lib/providers/sync_status_controller.dart)
|
|
— 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`](../../lib/providers/pick_controller.dart) — "being
|
|
picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/
|
|
`itemMatchesPickFilter`) — see `server.md`.
|
|
- [`FolderBrowser`](../../lib/providers/folder_browser.dart) — 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 `FilesView`s are keyed in `buildAppTabView`
|
|
so the
|
|
`IndexedStack` never hands one's State to the other.
|
|
- [`OfflineController`](../../lib/providers/offline_controller.dart) — 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`](../../lib/providers/connectivity_controller.dart)
|
|
— 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`](../../lib/providers/item_operations.dart) — 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 `ChangeNotifierProvider`s,
|
|
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 `Uri`s 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 `NooButton`s)
|
|
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. `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
|
|
the pinned `AppTab`s 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`](../../lib/widgets/synced_header_scaffold.dart) - 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
|
|
`NooStatusIcon`s) 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`](../../lib/widgets/breadcrumbs.dart) 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`](../../lib/widgets/marquee_title.dart) (`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`](../../lib/views/move_copy_destination_picker.dart),
|
|
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`](../../lib/widgets/move_copy_conflict_sheet.dart).
|
|
`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.)
|