- Release builds now sign with a dedicated keystore (local android/key.properties or CI secrets, both gitignored) instead of each machine's own debug key, so a downloaded release APK can actually update a previous install instead of failing with "App not installed" (mismatched signature). - The Files tab's "+" -> "Upload File" now goes through the same ShareUploadView destination picker and background foreground-service upload as receiving a file via Android's "Share to..." sheet, instead of a separate blocking in-app-only upload path. Removed the now-unused ServerProvider/NextcloudService.uploadFileFromPath. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 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 7 tabs — Files,
Photos, Favorites, 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). maxVisibleTabs (5) is less than the
total tab count, and ServerProvider._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 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.
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
ServerProvider's shared currentFolderPath/pathStack/items, and
owns its own local navigation state instead, fetching through the
stateless ServerProvider.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.