Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4611bc2142 | ||
|
|
0b7df833f6 | ||
|
|
ed747a0407 | ||
|
|
9c9e57ec22 | ||
|
|
8aa7015c3f | ||
|
|
88abca647d | ||
|
|
65b772d6e7 | ||
|
|
8179bf9683 | ||
|
|
af69e5e751 | ||
|
|
1a232fc71a | ||
|
|
16aadda7b8 | ||
|
|
fb4829d035 | ||
|
|
9f47d0a3e3 | ||
|
|
e6d96a6f9f | ||
|
|
840ec4c301 | ||
|
|
aeea4847b4 | ||
|
|
43f36ed532 | ||
|
|
5e5acc3fc6 | ||
|
|
5dea9038f7 | ||
|
|
a8b14b8744 | ||
|
|
5f4caa2a3b | ||
|
|
4927416277 | ||
|
|
58312511c5 | ||
|
|
a70be19f80 | ||
|
|
c18fc98dc3 | ||
|
|
b73f451fff | ||
|
|
f9653bc0f2 | ||
|
|
4e6477a3ae | ||
|
|
a127d72d56 | ||
|
|
0abea46b1c | ||
|
|
c0f702b00e | ||
|
|
8d17ae1f4b | ||
|
|
1b8bbaa22f | ||
|
|
72472c771a | ||
|
|
c9889c69b8 | ||
|
|
254db255a7 | ||
|
|
800e8ebf44 | ||
|
|
d2c34b000f | ||
|
|
c2540ec2ce | ||
|
|
56784ef212 | ||
|
|
51682a901d | ||
|
|
15eb8d3c74 | ||
|
|
f2bba2914e | ||
|
|
49ae712f5e | ||
|
|
ff5f04e9a1 | ||
|
|
faaec5365f | ||
|
|
722b66f7b1 | ||
|
|
dd0c27035a | ||
|
|
b1400e399f | ||
|
|
44a23c3632 |
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: flutter-kgp-doctor
|
||||
description: Diagnoses and fixes Flutter's "plugin applies Kotlin Gradle Plugin (KGP)" build warning - the one that names specific plugins (e.g. "Your app uses the following plugins that apply Kotlin Gradle Plugin (KGP): file_saver, pdfx") and links to the Built-in Kotlin migration guide. Use whenever `flutter build`/`flutter run` prints this warning, or when checking whether it's safe to upgrade a plugin that previously triggered it.
|
||||
tools: Read, Edit, Grep, Glob, Bash, WebFetch, WebSearch
|
||||
---
|
||||
|
||||
You fix Flutter's Kotlin Gradle Plugin (KGP) migration warning, which looks
|
||||
like:
|
||||
|
||||
```
|
||||
WARNING: Your app uses the following plugins that apply Kotlin Gradle Plugin (KGP): file_saver, pdfx
|
||||
Future versions of Flutter will fail to build if your app uses plugins that apply KGP.
|
||||
```
|
||||
|
||||
## Background
|
||||
|
||||
Flutter is moving to a model where the Flutter Gradle plugin applies Kotlin
|
||||
itself ("Built-in Kotlin"), rather than each Android plugin applying its own
|
||||
copy of the Kotlin Gradle Plugin in its `android/build.gradle`. Plugins that
|
||||
haven't migrated yet trigger this warning today and will hard-fail the build
|
||||
in a future Flutter release. The fix lives upstream, in the plugin's own
|
||||
`android/build.gradle` (removing its own `apply plugin:
|
||||
'kotlin-android'`/`id("kotlin-android")` in favor of Flutter's built-in
|
||||
support) - not in this app.
|
||||
|
||||
Reference: https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-app-developers
|
||||
|
||||
## What to do
|
||||
|
||||
1. Run `flutter build apk --release` (faster than `appbundle`) or use the
|
||||
warning text the user already has, to get the exact list of named
|
||||
plugins.
|
||||
2. For each plugin, find its current pinned version in `pubspec.yaml` and
|
||||
the resolved version in `pubspec.lock`.
|
||||
3. Check whether a newer published version has migrated:
|
||||
- Check the plugin's CHANGELOG.md (pub.dev shows it, or the package's
|
||||
GitHub repo) for entries mentioning "Built-in Kotlin", "KGP", or
|
||||
removing `kotlin-android` from its `android/build.gradle`.
|
||||
- If unsure from the changelog alone, fetch the candidate version's
|
||||
`android/build.gradle` from pub.dev/GitHub directly and check whether
|
||||
it still applies the Kotlin Gradle Plugin itself.
|
||||
4. If a migrated version exists and satisfies (or can reasonably relax) the
|
||||
app's other constraints:
|
||||
- Bump the version constraint in `pubspec.yaml`.
|
||||
- Run `flutter pub get`.
|
||||
- Rebuild (`flutter build apk --release` is enough to confirm) and
|
||||
verify that specific plugin no longer appears in the warning's list.
|
||||
- Run `flutter analyze` and skim for anything the upgrade broke (API
|
||||
changes) before calling it done.
|
||||
5. If no migrated version exists yet:
|
||||
- Do not attempt to silence the warning (no Gradle flags, no
|
||||
suppressing build output) - it is telling the truth about a real
|
||||
future break.
|
||||
- Do not fork or hand-patch the plugin's Android Gradle files inside
|
||||
`pub-cache` - that gets overwritten on the next `flutter pub get` and
|
||||
isn't tracked by the repo anyway.
|
||||
- Report which plugin(s) are still blocked, their current version, and
|
||||
whether the upstream issue tracker already has an open issue for this
|
||||
(search it) - file a note for the user rather than opening the issue
|
||||
yourself.
|
||||
|
||||
## Reporting
|
||||
|
||||
For each plugin named in the original warning, state one of:
|
||||
- **Fixed**: upgraded from `X` to `Y` in `pubspec.yaml`, confirmed the
|
||||
warning no longer names it.
|
||||
- **Blocked**: still on `X`, no migrated release exists yet, link to the
|
||||
changelog/issue you checked.
|
||||
|
||||
Keep the report short - a couple of lines per plugin is enough.
|
||||
@@ -8,104 +8,236 @@ lib/
|
||||
# MainShellView (bottom-nav shell), share-intent listener
|
||||
models/ # plain data classes (NextcloudItem, NextcloudShare,
|
||||
# SavedAccount, AppTab, ...)
|
||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||
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)
|
||||
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 it from the `ServerProvider` passed down or
|
||||
read via `context.watch`/`context.read`.
|
||||
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
|
||||
|
||||
There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart)
|
||||
(~1500 lines). It is created once in `main()` and provided at the root with
|
||||
`provider`'s `ChangeNotifierProvider`. It owns:
|
||||
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):
|
||||
|
||||
- **Multi-account state**: the list of saved accounts (`accounts`), which one
|
||||
is active (`activeAccountId`/`activeAccount`), and a `_sessionGeneration`
|
||||
counter 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. See `server.md` for the full account-switch/storage
|
||||
story.
|
||||
- auth/session state for whichever account is active (`isLoggedIn`,
|
||||
`isRestoringSession`, login-flow status)
|
||||
- the active `NextcloudService` instance (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 `_directoryCache` keyed by folder path (see `CachePolicy`)
|
||||
- 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.md` for exactly which is
|
||||
which and why
|
||||
- **Login lock** (`loginLockEnabled`/`lockAccountSwitching`/
|
||||
`lockHiddenFiles`/`needsUnlock`): an app-wide PIN/biometric gate via
|
||||
`AppLockService` (a thin wrapper over `local_auth` — this app never
|
||||
stores or hashes a PIN itself, it delegates entirely to whatever
|
||||
credential the OS already has configured). `needsUnlock` is
|
||||
`loginLockEnabled && !_isUnlocked`, where `_isUnlocked` is transient
|
||||
(never persisted) and reset to `false` on every backgrounding
|
||||
(`didChangeAppLifecycleState`, `AppLifecycleState.paused`) so the lock has
|
||||
real value rather than only firing once per cold start. `switchAccount`/
|
||||
`cycleToNextAccount`/`cycleToPreviousAccount` and *enabling* (not
|
||||
disabling) `showHiddenFiles`/`showHiddenPhotos` each call the shared
|
||||
`_passGate` helper, which no-ops unless both `loginLockEnabled` and the
|
||||
relevant per-feature toggle are on.
|
||||
- [`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.
|
||||
|
||||
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 under
|
||||
**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 in `_applyAccountPrefs` 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.
|
||||
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 `ServerProvider`
|
||||
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||
`AccountStore`/`AppLockService`.
|
||||
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 provider
|
||||
state, no named routes:
|
||||
`main.dart`'s `NextcloudApp` picks the app's `home` screen from
|
||||
`SessionController` state, no named routes:
|
||||
|
||||
- `provider.isRestoringSession` → `_SplashView` (monochrome app icon,
|
||||
- `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 `!provider.isLoggedIn` → `LoginView` (server-address entry + Login
|
||||
- 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 `provider.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||
- else `session.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||
- else `MainShellView`
|
||||
|
||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
||||
Photos, 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`).
|
||||
`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
|
||||
@@ -113,15 +245,169 @@ 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`.
|
||||
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.
|
||||
|
||||
Five of the six tabs (all but Files) plus each tab's own controls share
|
||||
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
||||
`MainShellView` builds its chrome from the Noo nav kit
|
||||
(`widgets/noo/nav/`) and switches between two layouts on
|
||||
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
||||
phone in landscape keeps the mobile layout and only tablet-class windows get
|
||||
the sidebar):
|
||||
|
||||
- **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 - each
|
||||
tab also wraps its whole `CustomScrollView` in `SafeArea(top: true,
|
||||
bottom: false, ...)` so that pinned header stays clear of the status bar
|
||||
once the floating top bar above it fully collapses (see
|
||||
`topBarSliver`'s own doc comment for why that reservation can't live
|
||||
inside the top bar itself). `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). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||
instead, a dropdown holding the same hidden-tabs + Settings content -
|
||||
see `styling.md`'s Gotchas for the wiring.
|
||||
- **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. `SearchView`, `AccountView` (Settings), the
|
||||
file-details sheet, the share sheet, and `ShareUploadView` (the
|
||||
share-to-upload destination picker) 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.
|
||||
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.)
|
||||
|
||||
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,454 @@
|
||||
# Noo Design System
|
||||
|
||||
Noo is a Nextcloud client for iOS, Android, macOS and Windows. ("Noo" is a placeholder name.)
|
||||
Its visual language is warm neutrals with one violet accent, flat surfaces, pill-shaped controls, and no gradients.
|
||||
|
||||
**Platform rule:** keep one visual language everywhere. Adapt only the navigation chrome (status bar, top bar, bottom bar, window controls) to each platform. Content components look the same on every platform.
|
||||
|
||||
Reference files in this project:
|
||||
- `Noo Screens.dc.html` has every screen on every platform, in light and dark.
|
||||
- `Noo Design System.dc.html` is the visual component sheet.
|
||||
- `noo-kit.js` holds the tokens (`TH`), the icon set (`SVG`/`ic`), file-type mapping (`KIND`) and mock data.
|
||||
- `Mobile Screen.dc.html` and `Desktop Screen.dc.html` are the reference builds for each screen.
|
||||
- [Noo — Missing Screens](https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4) is a live design canvas (Claude Design, not a static file in this repo) covering pieces this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, the Details sheet's tab switch, and the Selection action bar (mobile + desktop). It has both an iOS row (built first, ready for later) and an Android row (built to match right now) - same content, chrome adapted per the platform rule above. §4 below is the written-up version of what's approved there; go back to the canvas for pixel-level layout, not just the summary.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tokens
|
||||
|
||||
### 1.1 Color (semantic). Use these names in code.
|
||||
|
||||
| Token | Role | Light | Dark |
|
||||
|---|---|---|---|
|
||||
| `bg` | App background, desktop sidebar | `#F3EFE6` warm off-white | `#0E0D0B` |
|
||||
| `surface` (`sf`) | Cards, rows, bars, sheets, desktop main pane | `#FFFFFF` | `#25241F` |
|
||||
| `surface-2` (`sf2`) | Inputs, inset panels, secondary buttons | `#F6F5F2` | `#3D3C38` |
|
||||
| `surface-3` (`sf3`) | Progress tracks, toggle off, grabbers | `#EAE8E2` | `#5A5852` |
|
||||
| `line` (`ln`) | Dividers, 1px borders | `#EAE8E2` | `#3D3C38` |
|
||||
| `fg-1` | Primary text | `#0E0D0B` | `#F3EFE6` |
|
||||
| `fg-2` | Secondary text, idle icons | `#5A5852` | `#BDBAB1` |
|
||||
| `fg-3` | Meta text, placeholders, trailing icons | `#7C7A72` | `#9E9B92` |
|
||||
| `accent` (`ac`) | Primary fills (buttons, FAB, toggle on, progress) | `#8D0DE3` | `#8D0DE3` |
|
||||
| `accent-text` (`act`) | Accent text and icons, active nav | `#8D0DE3` | `#CBA5FD` |
|
||||
| `accent-soft` (`acs`) | Selected and active backgrounds, tonal buttons | `#ECDFFF` | `#2F0454` |
|
||||
| `danger` / `danger-soft` | Destructive text / bg | `#A8202A` / `#FDE7E7` | `#F49E9E` / `#560F18` |
|
||||
| `danger-fill` | Swipe-delete background (white text) | `#A8202A` | `#A8202A` |
|
||||
| `success` / `success-soft` | Synced, connected | `#3E7A23` / `#EAF5DF` | `#B7DD9F` / `#1E3F10` |
|
||||
| `warning` / `warning-soft` | Video file type | `#8C5A0E` / `#FBEFD0` | `#F0C26C` / `#4A3208` |
|
||||
| `info` / `info-soft` | Docs, images | `#2940A8` / `#E6ECFE` | `#B6C3FB` / `#1C2B73` |
|
||||
| `scrim` | Behind sheets, drawers, dialogs | `rgba(14,13,11,.45)` | `rgba(0,0,0,.6)` |
|
||||
|
||||
Rules:
|
||||
- Text on `accent` is always `#FFFFFF`. Accent-colored text uses `accent-text`, never `accent` in dark mode.
|
||||
- On-color text always uses the matching pair: `accent-text` on `accent-soft`, `danger` on `danger-soft`, and so on.
|
||||
- Don't use gradients. Don't add new colors. Avatar and photo placeholder fills use the secondary palette: `#8DCDE2 #96B0FD #E5D8BD #EEEDB3 #DA9AC7 #A7D296 #E9A6A7 #85D1BD #CBA5FD`, always with `#0E0D0B` text.
|
||||
|
||||
### 1.2 File-type tiles (`KIND`)
|
||||
| Kind | Background | Icon color | Lucide icon |
|
||||
|---|---|---|---|
|
||||
| folder | accent-soft | accent-text | `folder` |
|
||||
| pdf | danger-soft | danger | `file-text` |
|
||||
| doc / text | info-soft | info | `file-text` |
|
||||
| sheet | success-soft | success | `sheet` |
|
||||
| video | warning-soft | warning | `film` |
|
||||
| image | info-soft | info | `image` |
|
||||
| archive | surface-3 | fg-2 | `file-archive` |
|
||||
|
||||
Tile sizes: 40px/r12 for mobile rows, 30px/r9 for desktop rows, 44px/r12 for the share header, 32px/r10 for activity. The icon is half the tile size (20, 16, 24, 16).
|
||||
|
||||
### 1.3 Typography
|
||||
Fonts (Google Fonts): **Schibsted Grotesk** for headings (weights 500/600) and **Instrument Sans** for UI (weights 400/500/600). Load them with `https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap`. Don't use italics, all-caps or wide tracking.
|
||||
|
||||
| Style | Font | Size / line-height | Tracking | Use |
|
||||
|---|---|---|---|---|
|
||||
| Large title | Schibsted 600 | 34 / 0.9 | −3% | iOS large title |
|
||||
| Stat | Schibsted 600 | 32 (mobile) · 28 (desktop) / 0.9 | −3% | Offline GB, counters |
|
||||
| Title | Schibsted 500 | 22 / 1 | −2% | Android top bar, desktop toolbar |
|
||||
| Card title | Schibsted 500 | 17–20 / 1 | −2% | Settings cards, sheet sections, account name |
|
||||
| Group heading | Schibsted 500 | 18 / 1 | −2% | Photos month |
|
||||
| Body L | Instrument 400/500 | 16 / 1.2 | 0 | Mobile row title, settings label |
|
||||
| Body | Instrument 400/500 | 14–15 / 1.3 | 0 | Desktop rows, activity text, banners |
|
||||
| Label | Instrument 600 | 13 / 1 | 0 | Section labels (fg-2), chips, table headers (12, fg-3) |
|
||||
| Meta | Instrument 400 | 13 (mobile) · 12–13 (desktop) / 1.2 | 0 | Size · date, subtitles (fg-3) |
|
||||
| Nav label | Instrument 500 / 600 (active) | 12 Android · 10 iOS | 0 | Bottom bars |
|
||||
| Button | Instrument 600 | 14–16 / 1 | 0 | All buttons |
|
||||
| Mono | ui-monospace 400 | 13–14 | 0 | Share URLs, local paths |
|
||||
|
||||
### 1.4 Space, radius, elevation, motion
|
||||
- **Spacing** uses a 4px base: 4, 8, 12, 14, 16, 20, 24, 32. Mobile screen gutter is **12px** (cards) and 16px (bars). Desktop content gutter is **24px**.
|
||||
- **Radius:**
|
||||
- pills (`999px`) for every button, chip, segmented control, toggle, search field and badge
|
||||
- 20px for cards and grouped lists
|
||||
- 18px for grid cards and stat cards
|
||||
- 14px for inputs and inset panels
|
||||
- 12px for desktop sidebar items and selected table rows
|
||||
- 28px for the sheet top and drawer edge
|
||||
- 24px for desktop dialogs
|
||||
- screen corners are 48 on iOS and 36 on Android
|
||||
- **Elevation:** product UI is flat, with no shadows on buttons or cards. The only shadow is `0 24px 48px rgba(30,0,47,.14)`, and only on floating desktop dialogs. Separate things by surface color, not by shadow.
|
||||
- **Motion:** use `cubic-bezier(.7,0,.15,1)` with durations of 180, 280 and 480ms. Nothing bounces. Pressing a button scales it to 0.98.
|
||||
|
||||
### 1.5 Icons
|
||||
Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14, 16, 18, 20, 24 and 32. Icons render in the current text color. Tab icons: Files `folder`, Photos `images`, Favorites `star`, Offline `hard-drive-download`, Shares `share-2`, Recent `clock`, Activity `activity`, Trash `trash-2`, Settings `settings`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Components
|
||||
|
||||
**Button**
|
||||
- Always a pill. Heights: 52 (sheet CTA), 44 (mobile card), 40 (inline in a field), 36 (desktop toolbar), 28–32 (compact).
|
||||
- Variants:
|
||||
- *Primary*: accent fill, white text.
|
||||
- *Tonal*: accent-soft fill, accent-text.
|
||||
- *Secondary*: surface-2 fill, fg-1.
|
||||
- *Danger*: danger-soft fill, danger text.
|
||||
- *Outline*: 1.5px surface-3 border.
|
||||
- An icon goes on the left, with a 6–10px gap.
|
||||
|
||||
**FAB** (Android only)
|
||||
- An extended pill: 56px tall, accent fill, labelled "Upload".
|
||||
- Sits 16px from the right and bottom edges of the content area.
|
||||
- Stays mounted on every tab; it's only the extended label that's tied to
|
||||
Files/Photos. Elsewhere it collapses to an icon-only 56px circle or
|
||||
rounded square (see below) - still tappable, it always targets the Files
|
||||
tab's current folder - animating the width/label change rather than the
|
||||
button popping fully in/out as the Scaffold's default FAB transition
|
||||
would on every tab switch.
|
||||
- Corner radius follows the bottom bar style (Settings → Appearance):
|
||||
fully round (`pill`, 999) when the bar is floating, matching that mode's
|
||||
other already-rounder elements (the bar itself at 28px, its search
|
||||
satellite a full circle); a rounded square (`card`, 20) when the bar is
|
||||
attached, to suit that mode's flatter, edge-to-edge corners. Same radius
|
||||
whether the FAB is collapsed to a circle/square or expanded to the full
|
||||
pill shape.
|
||||
|
||||
**Chip**
|
||||
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
|
||||
- Idle: surface fill on mobile, or a 1px line border on desktop.
|
||||
- Selected: accent-soft fill with accent-text.
|
||||
- A trailing `chevron-down` means it opens a menu. A trailing `x` means it's an active filter.
|
||||
|
||||
**Segmented control**
|
||||
- A pill track (surface or surface-2) with 3px padding.
|
||||
- The active segment gets an accent-soft fill with accent-text at weight 600. Idle segments use fg-2 at weight 500.
|
||||
- Use it for the List/Grid toggle (icons only, 36×28), Shares scope, Theme, and default view.
|
||||
|
||||
**Toggle**
|
||||
- 48×28 on mobile, 44×26 on desktop, with a white knob 3px inset.
|
||||
- On: accent track. Off: surface-3 track. It looks the same on every platform.
|
||||
|
||||
**Search field**
|
||||
- A pill (radius 12 on the iOS inline field), 36–38px tall.
|
||||
- Surface fill on bg, or surface-2 fill on surface. Leading search icon and placeholder in fg-3.
|
||||
- Placement:
|
||||
- iOS: below the large title.
|
||||
- Android: an icon in the top bar that expands.
|
||||
- Desktop: 260px wide in the toolbar.
|
||||
|
||||
**Grouped list (card)**
|
||||
- A surface card with radius 20 and 1px gaps showing `line`, which draws the dividers.
|
||||
- An optional group label above it: 13/600 in fg-2, inset 8px.
|
||||
- Each group is 18px from the next.
|
||||
|
||||
**File row**
|
||||
- Mobile: 64px tall.
|
||||
- Left to right: tile 40, then a column with the name (16/500, one line, ellipsis) above a meta line (13, fg-3), then optional trailing icons, then the overflow menu (`ellipsis` on iOS, `ellipsis-vertical` on Android).
|
||||
- The meta line is `[status icon][shared icon] size · modified`.
|
||||
- A favorite shows an accent-text star before the overflow menu.
|
||||
- Trailing icon by screen: star in Favorites, `rotate-ccw` in Trash.
|
||||
- Desktop: a table row 52px tall.
|
||||
- Columns are `minmax(0,1fr) 180px 160px 120px`: Name, two data columns, then status icons and the overflow menu.
|
||||
- The header row is 36px, 12/600 in fg-3, with a sort arrow on the active column.
|
||||
- A selected row gets an accent-soft fill with radius 12.
|
||||
|
||||
**Status icons (14px)**
|
||||
- Synced: `circle-check` in success.
|
||||
- Syncing: `refresh-cw` in accent-text.
|
||||
- Error: `circle-alert` in danger, and the meta text says what to do ("Couldn't sync · Tap to retry").
|
||||
- Shared: `users` in fg-3.
|
||||
|
||||
**Swipe action**
|
||||
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent.
|
||||
- The block has a 20px icon above a 12/600 label.
|
||||
- Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it.
|
||||
- Swipe actions are set in Settings → Swipe on a file.
|
||||
|
||||
**Selection action bar** (Files, Photos, Favorites - anywhere with
|
||||
multi-select)
|
||||
- Replaces that screen's own sort/filter row in place while selecting -
|
||||
never a second bar stacked underneath it, and never the shell's own top
|
||||
bar/toolbar, which don't change for selection.
|
||||
- A `surface` card, radius 20, in the screen's usual gutter (12 mobile, 24
|
||||
desktop) - selection reads as a distinct mode, not a bare row of buttons
|
||||
on `bg`.
|
||||
- Left to right: a 36px `surface-2` close circle (`x`), then "N selected"
|
||||
(17/500), then the bulk actions, trailing-anchored. Fixed width, never
|
||||
scrolls: only the first few actions show inline (3 on mobile, 4 on
|
||||
desktop - there can be up to 9: favorite, share, download, delete, copy,
|
||||
move, rename, sync, details); the rest sit behind a trailing "More"
|
||||
button that opens the same grouped-list sheet a file row's own overflow
|
||||
menu (`ellipsis`/`ellipsis-vertical`) already uses. Which actions land in
|
||||
the inline slots vs. "More" is the user's own priority order (Settings
|
||||
→ Action bar, §4's 9-part list), not a fixed per-kind assignment.
|
||||
- Mobile: inline actions are plain 20px accent-text icons, no fill; "More"
|
||||
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
|
||||
`ellipsis-vertical`, fg-3) file rows use for their own overflow menu.
|
||||
- Desktop: inline actions are labelled tonal pills (accent-soft/
|
||||
accent-text), danger-soft/danger for the one labelled "Delete" - desktop
|
||||
has the room for labels, and the app's other toolbar actions are already
|
||||
labelled buttons rather than bare icons. "More" is a secondary pill
|
||||
(surface-2/fg-1) with a trailing `chevron-down`, so it reads as "opens a
|
||||
menu" rather than another bulk action.
|
||||
|
||||
**Grid card**
|
||||
- Surface fill, radius 18. Mobile uses 2 columns with a 10px gap; desktop uses 5 columns with a 16px gap.
|
||||
- The thumbnail area is 104–118px tall, filled with the file-type soft color and a 32px icon. Real thumbnails replace it when they exist.
|
||||
- Below it: the name (14/500) with an overflow menu, then the meta (12).
|
||||
|
||||
**Photo grid**
|
||||
- Square tiles with a 2px gap on mobile (3 columns, edge to edge) and a 4px gap on desktop (8 columns).
|
||||
- Tiles are grouped by month, with the group heading on the left and the count on the right.
|
||||
- Videos get a badge in the bottom-right: a `play` icon and the duration on `rgba(14,13,11,.7)`.
|
||||
|
||||
**Activity item**
|
||||
- Avatar 36 (initials on a secondary-palette color), then a sentence: **Actor** verb **object** tail.
|
||||
- Time in meta style. The file tile (32) goes on the right.
|
||||
|
||||
**Banner / summary card**
|
||||
- A surface card with radius 20 (surface-2 on desktop) and padding of 14–18.
|
||||
- The action sits on its own row below the description text, not squeezed
|
||||
onto the same line - a long retention message/caption and the action
|
||||
used to fight for the same row's width.
|
||||
- Trash: an info icon, the retention text, then "Empty trash" as a
|
||||
right-aligned danger text button on the row below.
|
||||
- Offline: the stat and caption, then a "Sync now" tonal button on the row
|
||||
below, then the 6px progress bar and the last-sync meta.
|
||||
|
||||
**Progress bar**
|
||||
- 6px tall pill. Surface-3 track with an accent fill.
|
||||
- Used for storage quota, cache use and the offline limit.
|
||||
|
||||
**Avatar**
|
||||
- A circle with initials, weight 600, at 0.36× the avatar size.
|
||||
- Sizes: 30–32 in bars, 36 in lists, 48 in the drawer, 52–56 on the account card.
|
||||
- The current user gets an accent-soft fill with accent-text.
|
||||
|
||||
**Settings row**
|
||||
- 52px tall, or 60px with a subtitle.
|
||||
- Left to right: an optional 20px icon in fg-2, then the label (16) with an optional subtitle (13, fg-3), then a trailing control.
|
||||
- Trailing control is one of: a toggle, a value in fg-3 with a chevron (desktop uses a 32px dropdown with radius 10), a segmented control, a pill button, or a status badge.
|
||||
- A destructive row uses danger text.
|
||||
- Exception: a segmented control with 3+ labelled segments (Theme's
|
||||
System/Light/Dark) gets its own row below the label instead of a
|
||||
trailing slot - three icon+label segments crammed in next to the label
|
||||
left each one too cramped to read. Label row, then the full-width
|
||||
segmented control on the row below, both on one continuous surface (no
|
||||
divider between them) - same shape the sort sheet's Ascending/Descending
|
||||
control and Photos' filter-sheet type control already use.
|
||||
|
||||
**Tab order row**
|
||||
- A grip icon (`grip-vertical`, fg-3), the tab icon, the label, then a pin button: a 36px circle (30 on desktop) that is accent-soft with `pin` when pinned, or surface-2 with `pin-off` when not.
|
||||
- Allow at most 5 pinned tabs.
|
||||
|
||||
**Sheet** (mobile)
|
||||
- Surface fill, radius 28 at the top. A 36×5 grabber in surface-3. Padding 20, with 22px between sections.
|
||||
- It sits on a scrim.
|
||||
|
||||
**Dialog** (desktop)
|
||||
- 540px wide, radius 24, padding 24, sitting on a scrim.
|
||||
- Header: file tile, title "Share "<name>"", and a close button (32px circle, surface-2).
|
||||
|
||||
**Drawer** (mobile)
|
||||
- 316px wide, surface fill, radius 28 on the trailing edge, on a scrim.
|
||||
- Top to bottom:
|
||||
- account block with a chevron to switch accounts
|
||||
- storage bar
|
||||
- "More" label, then the unpinned tabs as 52px pill items (icon 24, label 16/500, trailing count)
|
||||
- Settings
|
||||
- an "Edit tabs" link at the bottom, in accent-text
|
||||
|
||||
---
|
||||
|
||||
## 3. Navigation & platform mapping
|
||||
|
||||
**Tabs** (the user sets the order): Files, Photos, Favorites, Offline, Shares, Recent, Activity, Trash. Settings is always separate.
|
||||
|
||||
### Mobile
|
||||
|
||||
Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer, which opens from the top-left menu icon.
|
||||
|
||||
| Element | iOS | Android |
|
||||
|---|---|---|
|
||||
| Status bar | 54px, Dynamic Island | 40px |
|
||||
| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, avatar - see "Search" below for what replaces `search` there, and "Navigation menu" for `menu` |
|
||||
| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. |
|
||||
| Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. Product UI otherwise stays flat (see 1.4), but this is the one other place - besides desktop dialogs - that gets the app's one shadow: a 1px `line` border plus the dialog shadow, since a bar that's genuinely floating above scrolling content needs to read as elevated. Row height drops (iOS 50, same as attached; Android 64 vs 80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (25 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it - which means every tab's scrollable list has to pad its own bottom enough to clear the bar's footprint, since nothing does that for it automatically once the body draws behind the bar. The Android FAB stays fully round in this mode (see "Upload" below), consistent with the bar's own rounder shape. |
|
||||
| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. |
|
||||
| Search (top bar, when not in the bottom bar) | A real inline search field (`NooSearchField`, non-editable launcher) below the large title | A search-field-styled launcher takes the title's own flexible slot instead of a bare `search` icon beside it - no second row to put it under the way iOS has |
|
||||
| Navigation menu (Settings → Appearance → "Navigation menu", both platforms) | `drawer` (default): the `menu` icon opens the left `Drawer` (hidden tabs + Settings), unchanged. `avatarMenu`: no `menu` icon at all - the avatar button opens a dropdown instead (`showAvatarMenu`), anchored under the top bar, full width minus the same 16px gutter every content row uses, with the same hidden-tabs + Settings content the drawer holds (not its storage bar - this is a quick menu, not the drawer's full account summary). The avatar sits in the exact same (right-end) position either way, and the dropdown's own header repeats it there too, so the open menu reads as having grown out of the button that opened it. Not a mandatory replacement: this halves the top-corner surface a thumb has to reach (from `menu` + avatar down to avatar alone) without costing any permanent screen width the way an earlier side-rail exploration would have. |
|
||||
| Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
|
||||
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
|
||||
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
|
||||
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
|
||||
|
||||
### Desktop
|
||||
|
||||
Desktop has a 256px sidebar in `bg` and a main pane in `surface`, separated by a 1px line.
|
||||
|
||||
**Sidebar**, top to bottom:
|
||||
- account switcher card
|
||||
- the 5 pinned tabs
|
||||
- a divider
|
||||
- the remaining tabs
|
||||
- a spacer
|
||||
- the storage meter
|
||||
- Settings
|
||||
|
||||
Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The active item gets an accent-soft fill with accent-text at weight 600. Counts sit on the right: Trash 12, Offline errors, new shares.
|
||||
|
||||
**Toolbar** is 64px tall:
|
||||
- the title (22px Schibsted Grotesk), with a breadcrumb in Files
|
||||
- a spacer
|
||||
- the search field
|
||||
- context actions: List/Grid toggle and Upload in Files, Upload in Photos, Empty trash in Trash, Sync now in Offline
|
||||
|
||||
| Element | macOS | Windows |
|
||||
|---|---|---|
|
||||
| Window | Radius 12 | Radius 8 |
|
||||
| Controls | Traffic lights at the top of the sidebar | 40px title bar with the app mark and name; minimize, maximize and close buttons 46px wide |
|
||||
| Navigation | Back and forward chevrons at the start of the toolbar | Not shown in the toolbar |
|
||||
| Biometric | "Touch ID" | "Windows Hello" |
|
||||
| Local path | `~/Noo/…` | `C:\Users\…\Noo` |
|
||||
| Swipe setting | "Trackpad swipe on a file" | "Touch swipe on a file" |
|
||||
|
||||
---
|
||||
|
||||
## 4. Screen recipes
|
||||
|
||||
- **Files:**
|
||||
- Toolbar: sort chip (`Name ↑`), type filter chip, List/Grid segmented toggle.
|
||||
- Desktop adds Modified and Shared filter chips, an active-filter chip, and an item count.
|
||||
- Content: a list, or a grid when the toggle is set.
|
||||
- **Photos:** a sort chip and a filter chip (mirroring Files' own controls
|
||||
row - no List/Grid toggle, the grid is the only view). The type filter
|
||||
(All, Photos, Videos - Camera isn't backed by real data, see the view's
|
||||
doc comment) lives inside the filter sheet as a full-width segmented
|
||||
control, the same "label, then the control on its own row below" shape
|
||||
Settings' Theme row uses, not its own row of chips. Every icon (All,
|
||||
Photos, Videos in the filter sheet; All, Files, Folders in Files' own
|
||||
filter sheet, same treatment) always shows; only the *selected* segment
|
||||
also shows its label (`NooSegmentedControl`'s `labelOnlySelected`) - the
|
||||
same List/Grid-toggle-style pill both screens' filter sheets use, not
|
||||
Files' old checkmark list or Photos' own always-labelled track. Then the
|
||||
photo grid grouped by month.
|
||||
- **Favorites:** a file list with a star as the trailing icon.
|
||||
- **Offline:** a summary card (or 4 stat cards on desktop), then a list whose rows show sync status icons and status meta.
|
||||
- **Shares:** a segmented control (With you, By you, Links), then a list. The
|
||||
meta reads "Owner · Permission". No per-row overflow menu on any of the
|
||||
three scopes - tapping a row (file or folder) opens the full Share sheet
|
||||
for that item (fetched fresh via `FilesController.fetchItemAtPath`, since
|
||||
a share only carries enough metadata for its own row), the same sheet
|
||||
Files/Photos open from their own Share action - that's already where
|
||||
copying a link or removing access lives, so a second, row-local menu here
|
||||
was redundant.
|
||||
- **Recent:** a list grouped into Today, Yesterday and This week. The meta is the action plus the location.
|
||||
- **Activity:** the feed grouped by day. Desktop limits it to 760px wide.
|
||||
- **Trash:** a retention banner, then a list. Mobile rows get a restore icon; desktop rows get a tonal "Restore" pill.
|
||||
- **Settings:** sections in this order:
|
||||
1. Account card
|
||||
2. Accounts (list, then "Add account")
|
||||
3. Security
|
||||
4. File sync
|
||||
5. Files cache
|
||||
6. Appearance
|
||||
7. Tabs
|
||||
8. Action bar - a reorder-only list (no pin/hide, unlike Tabs): the
|
||||
priority order for the Selection action bar's bulk actions (favorite,
|
||||
share, download, delete, copy, move, rename, sync, details) - the
|
||||
first few (3 mobile / 4 desktop) land in the bar's fixed inline
|
||||
slots, the rest sit behind "More". See §2 "Selection action bar".
|
||||
9. Swipe on a file
|
||||
|
||||
Mobile is a two-level menu, the way native iOS/Android Settings apps
|
||||
work: the account card stays pinned at the top of a single top-level
|
||||
list, and every other section (2-9 above) becomes one tappable
|
||||
`NooSettingsRow` - icon, title, chevron - in a `NooGroupedList` below it.
|
||||
Tapping a row pushes a new screen (`NooTopBar`/`NooTopBarBack`) holding
|
||||
just that section's own content full-screen, so no page is ever more
|
||||
than one category deep and no section needs to be individually
|
||||
collapsible any more. Two earlier designs were tried and dropped: a
|
||||
trailing jump rail (one small icon per section, pinned where the
|
||||
scrollbar would sit), and - after that - one long column of every
|
||||
section inline, each individually collapsible (`NooGroupedList`'s
|
||||
`collapsible` param) so the page could at least be collapsed down. Both
|
||||
scrolled the *same* page to or past an anchor; a genuinely separate
|
||||
pushed screen per category removes the scroll-depth problem outright
|
||||
instead of just working around it. Desktop is unchanged: a 2-column grid
|
||||
of cards with a 1px line and radius 20, wide enough to see most sections
|
||||
without scrolling, so it gets neither a menu nor collapsing.
|
||||
- **Share sheet / dialog:** sections in this order:
|
||||
1. Header: file tile, name, size · folder, and close.
|
||||
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
|
||||
3. **Share link:** a toggle, then the URL field in mono with a primary "Copy link" button, then option chips (permission, expiry, password, allow download).
|
||||
4. **Send file directly:** an outline button, with a caption saying that link settings don't apply.
|
||||
- **Details sheet:** header (file tile, name, size · folder, close) as in the
|
||||
Share sheet above, then a segmented control switching Info/Versions/Activity
|
||||
in place below it - there's no separate tab-strip component, so the
|
||||
segmented control (already used for List/Grid and the Shares scope) is the
|
||||
one that does this job too. Info is a flat label/value list (Size, Type,
|
||||
Location, Modified, Created, ...). Versions and Activity reuse their own
|
||||
row/feed treatment. Same on every platform - this sheet has no iOS/Android
|
||||
split.
|
||||
- **Media viewer** (the full-screen photo/video viewer): the stage is `bg`,
|
||||
following the app's own theme rather than a fixed black - a black stage
|
||||
in light mode read as jarringly out of place. A translucent, blurred top
|
||||
bar (back, filename, meta) and bottom bar float over the media in a
|
||||
`surface`-tinted panel (also theme-following, at a higher opacity than a
|
||||
typical blur so it stays legible over arbitrary photo/video brightness
|
||||
underneath - icons/text are `fg1`, matching); this is the one deliberate
|
||||
exception to "no blur" in product UI, since it's chrome over photo/video
|
||||
content, not over the app's own surfaces. A short edge gradient outside
|
||||
each panel (toward `surface`, not a fixed black) extends that same
|
||||
contrast a little past the panel's hard edge. Both bars' background
|
||||
extends edge-to-edge behind the status bar/gesture area, with only their
|
||||
content padded clear of it. Back is a plain arrow (`arrow-left`), not the iOS
|
||||
chevron+label pushed-screen pattern - platform split still to do. The
|
||||
bottom bar holds every action in one row (share, favorite, open
|
||||
externally, download, delete, details) on every platform; don't add a
|
||||
top-bar overflow menu for the same actions. Video adds a transport row
|
||||
above the action row: time · seek bar · time, then play/pause and mute
|
||||
centered below it. A photo can be pinch-zoomed; while zoomed in, the
|
||||
gallery's own left/right swipe between items is disabled so panning
|
||||
around the zoomed photo doesn't also swipe to the next one - it comes
|
||||
back the moment the photo returns to its un-zoomed scale. A PDF preview
|
||||
stays at its fit-width scale as its floor rather than letting the user
|
||||
zoom out past it, since going below 1.0 scale removes any limit on how
|
||||
far the page can be panned, including off-screen entirely.
|
||||
- **Search:** pushed from the shell's search entry point (`menu`/`search`
|
||||
icon in the top bar, or the inline field below an iOS large title -
|
||||
DESIGN_SYSTEM §2 "Search field" placement). The destination screen is one
|
||||
shared recipe for both platforms: back arrow + the pill `NooSearchField`
|
||||
(not the inset-panel text-field shape - a search field is always the pill,
|
||||
except the one named iOS-inline exception) taking over the top row,
|
||||
autofocus, a clear button once there's a query. Below it: a file list
|
||||
(mobile rows / desktop table, same as Files), or a centered icon + short
|
||||
sentence-case message for the empty ("Search your files") and no-results
|
||||
states.
|
||||
- **Upload / Move / Copy destination picker:** a pushed screen (outside the
|
||||
tab shell, so it carries its own complete top bar) titled "Upload to" /
|
||||
"Move to" / "Copy to". Back arrow (Android) or "Cancel" text (iOS) leading,
|
||||
no trailing action. The same `FilesControlsRow` (sort chip, filter chip,
|
||||
List/Grid toggle right-anchored) Files itself uses, then a breadcrumb row,
|
||||
then a folder-only list/grid (no files: this screen only browses folders)
|
||||
using the standard file row/tile/grid-card at the folder kind. Bottom bar:
|
||||
a centered meta line ("Moving 3 items") above a full-width 52px primary
|
||||
CTA ("Upload here" / "Move here" / "Copy here") - a count, never a
|
||||
filename, even for a single item.
|
||||
|
||||
---
|
||||
|
||||
## 5. Copy
|
||||
- Use plain, active and short sentences, under 15 words. Say "you".
|
||||
- Name the problem and the fix: "Couldn't sync · Tap to retry", not "An error occurred".
|
||||
- Use sentence case everywhere and no all-caps. Use a middle dot ( · ) to separate meta values.
|
||||
- Don't use emoji in the product UI.
|
||||
@@ -0,0 +1,340 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<script src="noo-kit.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#8D0DE3;text-decoration:none}
|
||||
a:hover{color:#6E08B3}
|
||||
</style>
|
||||
</helmet>
|
||||
<sc-if value="{{ ready }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="{{ root }}">
|
||||
|
||||
<sc-if value="{{ win }}">
|
||||
<div style="height:40px;flex:none;display:flex;align-items:center;background:var(--bg);color:var(--fg2)">
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:0 16px;flex:1;font:400 12px/1 var(--ft)"><div style="width:16px;height:16px;border-radius:4px;background:var(--ac)"></div>Noo</div>
|
||||
<div style="width:46px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ i16.minus }}"></span></div>
|
||||
<div style="width:46px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ i14.square }}"></span></div>
|
||||
<div style="width:46px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ i16.x }}"></span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="flex:1;min-height:0;display:flex">
|
||||
<div style="width:256px;flex:none;background:var(--bg);display:flex;flex-direction:column;padding:12px 12px 16px;box-sizing:border-box;gap:2px">
|
||||
<sc-if value="{{ mac }}">
|
||||
<div style="height:32px;display:flex;align-items:center;gap:8px;padding:0 8px 8px"><div style="width:12px;height:12px;border-radius:999px;background:#FF5F57"></div><div style="width:12px;height:12px;border-radius:999px;background:#FEBC2E"></div><div style="width:12px;height:12px;border-radius:999px;background:#28C840"></div></div>
|
||||
</sc-if>
|
||||
<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:14px;background:var(--sf);margin-bottom:14px">
|
||||
<div style="width:32px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft);flex:none">MR</div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="font:600 14px/1 var(--ft)">Marina Rocha</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div>
|
||||
<span style="{{ chevG }}"></span>
|
||||
</div>
|
||||
<sc-for list="{{ navA }}" as="n" hint-placeholder-count="5">
|
||||
<sc-if value="{{ n.on }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft)"><span style="{{ n.ico }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft)">{{ n.count }}</div></div></sc-if>
|
||||
<sc-if value="{{ n.off }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;color:var(--fg1);font:500 14px/1 var(--ft)"><span style="{{ n.icoG }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft);color:var(--fg3)">{{ n.count }}</div></div></sc-if>
|
||||
</sc-for>
|
||||
<div style="height:1px;background:var(--ln);margin:8px 12px"></div>
|
||||
<sc-for list="{{ navB }}" as="n" hint-placeholder-count="3">
|
||||
<sc-if value="{{ n.on }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft)"><span style="{{ n.ico }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft)">{{ n.count }}</div></div></sc-if>
|
||||
<sc-if value="{{ n.off }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;color:var(--fg1);font:500 14px/1 var(--ft)"><span style="{{ n.icoG }}"></span><div style="flex:1">{{ n.label }}</div><div style="font:500 12px/1 var(--ft);color:var(--fg3)">{{ n.count }}</div></div></sc-if>
|
||||
</sc-for>
|
||||
<div style="flex:1"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 12px 14px">
|
||||
<div style="display:flex;justify-content:space-between;font:400 12px/1 var(--ft);color:var(--fg2)"><span style="font-weight:500;color:var(--fg1)">Storage</span><span>38.2 of 100 GB</span></div>
|
||||
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div>
|
||||
</div>
|
||||
<sc-if value="{{ setOn }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft)"><span style="{{ i18.settings }}"></span>Settings</div></sc-if>
|
||||
<sc-if value="{{ setOff }}"><div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;color:var(--fg1);font:500 14px/1 var(--ft)"><span style="{{ setIcoG }}"></span>Settings</div></sc-if>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;min-width:0;background:var(--sf);display:flex;flex-direction:column;position:relative;border-left:1px solid var(--ln)">
|
||||
<div style="height:64px;flex:none;display:flex;align-items:center;gap:12px;padding:0 20px 0 24px;border-bottom:1px solid var(--ln)">
|
||||
<sc-if value="{{ mac }}"><div style="display:flex;gap:4px;color:var(--fg3);margin-right:4px"><span style="{{ i20.chevL }}"></span><span style="{{ i20.chevR }}"></span></div></sc-if>
|
||||
<div style="display:flex;align-items:center;gap:8px;font:500 22px/1 var(--fd);letter-spacing:-0.02em">{{ title }}<sc-if value="{{ s.files }}"><span style="{{ chevG }}"></span><span style="font:400 15px/1 var(--ft);letter-spacing:0;color:var(--fg2);white-space:nowrap">All files</span></sc-if></div>
|
||||
<div style="flex:1"></div>
|
||||
<sc-if value="{{ notSettings }}"><div style="width:260px;height:36px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;gap:8px;padding:0 14px;color:var(--fg3);font:400 14px/1 var(--ft)"><span style="{{ i16.search }}"></span>Search in {{ title }}</div></sc-if>
|
||||
<sc-if value="{{ s.files }}">
|
||||
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="{{ segL }}"><span style="{{ i16.list }}"></span></div><div style="{{ segG }}"><span style="{{ i16.grid }}"></span></div></div>
|
||||
<div style="height:36px;padding:0 16px 0 12px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i18.plus }}"></span>Upload</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ s.photos }}"><div style="height:36px;padding:0 16px 0 12px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i18.upload }}"></span>Upload</div></sc-if>
|
||||
<sc-if value="{{ s.trash }}"><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.trash }}"></span>Empty trash</div></sc-if>
|
||||
<sc-if value="{{ s.offline }}"><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sync }}"></span>Sync now</div></sc-if>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;min-height:0;overflow:hidden;display:flex;flex-direction:column;padding:0 24px">
|
||||
|
||||
<sc-if value="{{ s.files }}">
|
||||
<div style="flex:none;display:flex;align-items:center;gap:8px;padding:14px 0 10px">
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.filter }}"></span>All types<span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap">Modified<span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap">Shared<span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.offline }}"></span>Available offline<span style="{{ i14.x }}"></span></div>
|
||||
<div style="flex:1"></div>
|
||||
<div style="font:400 13px/1 var(--ft);color:var(--fg3)">10 items · 440 MB</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ s.trash }}">
|
||||
<div style="flex:none;margin:16px 0 4px;padding:12px 16px;border-radius:14px;background:var(--sf2);display:flex;gap:10px;align-items:center;color:var(--fg2);font:400 14px/1.2 var(--ft)"><span style="{{ i18.info }}"></span>Items here are deleted for good after 30 days.</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ s.offline }}">
|
||||
<div style="flex:none;display:grid;grid-template-columns:minmax(0,2fr) repeat(3,minmax(0,1fr));gap:12px;padding:18px 0 8px">
|
||||
<div style="padding:16px 18px;border-radius:18px;background:var(--sf2);display:flex;flex-direction:column;gap:10px"><div style="display:flex;justify-content:space-between;align-items:baseline"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em">2.4 GB</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">of 5 GB limit</div></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:48%;height:100%;background:var(--ac)"></div></div></div>
|
||||
<div style="padding:16px 18px;border-radius:18px;background:var(--sf2);display:flex;flex-direction:column;gap:8px"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em">142</div><div style="font:400 13px/1 var(--ft);color:var(--fg2)">Files up to date</div></div>
|
||||
<div style="padding:16px 18px;border-radius:18px;background:var(--sf2);display:flex;flex-direction:column;gap:8px"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--act)">6</div><div style="font:400 13px/1 var(--ft);color:var(--fg2)">Syncing now</div></div>
|
||||
<div style="padding:16px 18px;border-radius:18px;background:var(--dgs);display:flex;flex-direction:column;gap:8px"><div style="font:600 28px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--dg)">1</div><div style="font:400 13px/1 var(--ft);color:var(--dg)">Needs attention</div></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ s.shares }}">
|
||||
<div style="flex:none;display:flex;padding:14px 0 8px"><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px">
|
||||
<div style="height:32px;padding:0 16px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">With you</div>
|
||||
<div style="height:32px;padding:0 16px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">By you</div>
|
||||
<div style="height:32px;padding:0 16px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Links</div>
|
||||
</div></div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showTable }}">
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="1">
|
||||
<sc-if value="{{ g.hasLabel }}"><div style="padding:18px 12px 6px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div></sc-if>
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) 180px 160px 120px;align-items:center;height:36px;padding:0 12px;border-bottom:1px solid var(--ln);font:600 12px/1 var(--ft);color:var(--fg3)">
|
||||
<div style="display:flex;align-items:center;gap:4px">{{ c0 }}<sc-if value="{{ sortable }}"><span style="{{ i14.up }}"></span></sc-if></div><div>{{ c1 }}</div><div>{{ c2 }}</div><div></div>
|
||||
</div>
|
||||
<sc-for list="{{ g.rows }}" as="f" hint-placeholder-count="8">
|
||||
<div style="{{ f.rs }}">
|
||||
<div style="display:flex;align-items:center;gap:12px;min-width:0"><div style="{{ f.tile }}"><span style="{{ f.ico }}"></span></div><div style="font:500 14px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div></div>
|
||||
<div style="font:400 13px/1.2 var(--ft);color:var(--fg2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.a }}</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;font:400 13px/1.2 var(--ft);color:var(--fg2);white-space:nowrap;overflow:hidden"><sc-if value="{{ f.bInline }}"><span style="{{ f.badge }}"></span></sc-if>{{ f.b }}</div>
|
||||
<div style="display:flex;align-items:center;justify-content:flex-end;gap:10px;color:var(--fg3)">
|
||||
<sc-if value="{{ f.bIcon }}"><span style="{{ f.badge }}"></span></sc-if>
|
||||
<sc-if value="{{ f.shared }}"><span style="{{ i16.users }}"></span></sc-if>
|
||||
<sc-if value="{{ f.fav }}"><span style="{{ starSm }}"></span></sc-if>
|
||||
<sc-if value="{{ restore }}"><div style="height:28px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.restore }}"></span>Restore</div></sc-if>
|
||||
<span style="{{ i18.moreH }}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showGrid }}">
|
||||
<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:16px;padding-top:6px">
|
||||
<sc-for list="{{ gridRows }}" as="f" hint-placeholder-count="10">
|
||||
<div style="border:1px solid var(--ln);border-radius:18px;overflow:hidden;display:flex;flex-direction:column;background:var(--sf)">
|
||||
<div style="{{ f.thumb }}"><span style="{{ f.icoL }}"></span></div>
|
||||
<div style="padding:10px 10px 12px 14px;display:flex;flex-direction:column;gap:5px">
|
||||
<div style="display:flex;align-items:center;gap:4px"><div style="flex:1;min-width:0;font:500 14px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div><span style="{{ moreSm }}"></span></div>
|
||||
<div style="display:flex;align-items:center;gap:5px;font:400 12px/1.2 var(--ft);color:var(--fg3)"><sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if>{{ f.meta }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.photos }}">
|
||||
<div style="flex:none;display:flex;gap:8px;padding:14px 0 4px">
|
||||
<div style="height:32px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">All</div>
|
||||
<div style="height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;font:500 13px/1 var(--ft);white-space:nowrap">Photos</div>
|
||||
<div style="height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;font:500 13px/1 var(--ft);white-space:nowrap">Videos</div>
|
||||
<div style="height:32px;padding:0 14px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.camera }}"></span>Camera uploads</div>
|
||||
</div>
|
||||
<sc-for list="{{ photoGroups }}" as="g" hint-placeholder-count="2">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:16px 0 10px"><div style="font:500 18px/1 var(--fd);letter-spacing:-0.02em">{{ g.label }}</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">{{ g.count }}</div></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(8,minmax(0,1fr));gap:4px">
|
||||
<sc-for list="{{ g.tiles }}" as="t" hint-placeholder-count="16">
|
||||
<div style="{{ t.s }}"><sc-if value="{{ t.vid }}"><div style="position:absolute;right:6px;bottom:6px;height:20px;padding:0 7px 0 5px;border-radius:999px;background:rgba(14,13,11,0.7);color:#FFFFFF;display:flex;align-items:center;gap:3px;font:600 11px/1 var(--ft)"><span style="{{ i14.play }}"></span>{{ t.dur }}</div></sc-if></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.activity }}">
|
||||
<div style="max-width:760px;width:100%;display:flex;flex-direction:column">
|
||||
<sc-for list="{{ act }}" as="g" hint-placeholder-count="2">
|
||||
<div style="padding:20px 0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div>
|
||||
<sc-for list="{{ g.items }}" as="a" hint-placeholder-count="3">
|
||||
<div style="display:flex;gap:14px;padding:14px 0;align-items:center;border-bottom:1px solid var(--ln)">
|
||||
<div style="{{ a.av }}">{{ a.who }}</div>
|
||||
<div style="flex:1;min-width:0;font:400 14px/1.3 var(--ft)"><span style="font-weight:600">{{ a.name }}</span> {{ a.verb }} <span style="font-weight:600">{{ a.obj }}</span> {{ a.tail }}</div>
|
||||
<div style="{{ a.tile }}"><span style="{{ a.ico }}"></span></div>
|
||||
<div style="width:48px;text-align:right;font:400 13px/1 var(--ft);color:var(--fg3)">{{ a.time }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.settings }}">
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:20px 0;align-items:start">
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="padding:20px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;gap:14px;align-items:center"><div style="width:52px;height:52px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 18px/1 var(--ft);flex:none">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:5px"><div style="font:500 20px/1 var(--fd);letter-spacing:-0.02em">Marina Rocha</div><div style="font:400 14px/1 var(--ft);color:var(--fg2)">marina@rocha.family</div></div><div style="height:24px;padding:0 10px;border-radius:999px;background:var(--oks);color:var(--ok);display:flex;align-items:center;font:600 12px/1 var(--ft);white-space:nowrap">Connected</div></div>
|
||||
<div style="display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px 16px;font:400 14px/1.2 var(--ft)"><span style="color:var(--fg3)">Server</span><span>cloud.rocha.family</span><span style="color:var(--fg3)">Version</span><span>Nextcloud 30.0.2</span><span style="color:var(--fg3)">Storage</span><div style="display:flex;align-items:center;gap:10px"><div style="flex:1;height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div><span style="color:var(--fg2)">38.2 of 100 GB</span></div></div>
|
||||
<div style="display:flex;gap:8px"><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i14.external }}"></span>Manage on web</div><div style="height:36px;padding:0 16px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i14.logout }}"></span>Log out</div></div>
|
||||
</div>
|
||||
<div style="padding:8px 20px 12px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Accounts</div>
|
||||
<div style="display:flex;align-items:center;gap:12px;height:56px;border-bottom:1px solid var(--ln)"><div style="width:32px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Marina Rocha</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div><span style="{{ checkA }}"></span></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;height:56px;border-bottom:1px solid var(--ln)"><div style="width:32px;height:32px;border-radius:999px;background:#85D1BD;color:#0E0D0B;display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Marina · Work</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">files.acme-studio.com</div></div><div style="font:600 13px/1 var(--ft);color:var(--act)">Switch</div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;height:48px;color:var(--act);font:600 14px/1 var(--ft)"><div style="width:32px;display:flex;justify-content:center"><span style="{{ i18.plus }}"></span></div>Add account</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Security</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;min-height:60px;border-bottom:1px solid var(--ln)"><span style="{{ bioIco }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 14px/1.1 var(--ft)">{{ bioLabel }}</div><div style="font:400 12px/1.2 var(--ft);color:var(--fg3)">Needed every time you open Noo</div></div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.timer }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Lock after</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft)">5 minutes idle<span style="{{ i14.chevD }}"></span></div></div>
|
||||
</div>
|
||||
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">File sync</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;min-height:60px;border-bottom:1px solid var(--ln)"><span style="{{ i18.folder }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 14px/1.1 var(--ft)">Local sync folder</div><div style="font:400 12px/1.2 var(--ft);color:var(--fg3);font-family:ui-monospace,Menlo,monospace">{{ syncPath }}</div></div><div style="height:32px;padding:0 12px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">Change</div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.sync }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Sync automatically</div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.download }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Download files on demand</div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.wifi }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Pause on metered networks</div><div style="width:44px;height:26px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.settings2 }}">
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:20px 0;align-items:start">
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Files cache</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;padding:10px 0 14px;border-bottom:1px solid var(--ln)"><div style="display:flex;justify-content:space-between;font:400 14px/1 var(--ft)"><span>Cache in use</span><span style="color:var(--fg2)">1.2 of 2 GB</span></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:60%;height:100%;background:var(--ac)"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.database }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Cache limit</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft)">2 GB<span style="{{ i14.chevD }}"></span></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.trash }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Clear cached files</div><div style="height:32px;padding:0 14px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">Clear cache</div></div>
|
||||
</div>
|
||||
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">Appearance</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:56px;border-bottom:1px solid var(--ln)"><span style="{{ i18.palette }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Theme</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:28px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">System</div><div style="height:28px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Light</div><div style="height:28px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Dark</div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:56px;border-bottom:1px solid var(--ln)"><span style="{{ i18.grid }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Default file view</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:28px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.list }}"></span>List</div><div style="height:28px;padding:0 12px;display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--fg2)"><span style="{{ i14.grid }}"></span>Grid</div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.hidden }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Show hidden files</div><div style="width:44px;height:26px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="padding:8px 20px 12px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 4px;display:flex;justify-content:space-between;align-items:baseline"><div style="font:500 17px/1 var(--fd);letter-spacing:-0.02em">Sidebar tabs</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">Drag to reorder</div></div>
|
||||
<div style="padding:4px 0 8px;font:400 12px/1.3 var(--ft);color:var(--fg3)">Pinned tabs show at the top of the sidebar, and in the bottom bar on mobile.</div>
|
||||
<sc-for list="{{ tabRows }}" as="t" hint-placeholder-count="8">
|
||||
<div style="display:flex;align-items:center;gap:12px;height:44px;border-bottom:1px solid var(--ln)"><span style="{{ gripS }}"></span><span style="{{ t.ico }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">{{ t.label }}</div><div style="{{ t.pinS }}"><span style="{{ t.pinI }}"></span></div></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="padding:8px 20px 8px;border-radius:20px;border:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="padding:12px 0 8px;font:500 17px/1 var(--fd);letter-spacing:-0.02em">{{ swipeTitle }}</div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px;border-bottom:1px solid var(--ln)"><span style="{{ i18.swipe }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Swipe left</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--dg)"><span style="{{ i14.trash }}"></span>Delete<span style="{{ i14.chevD }}"></span></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;height:52px"><span style="{{ i18.swipe }}"></span><div style="flex:1;font:400 14px/1 var(--ft)">Swipe right</div><div style="height:32px;padding:0 10px 0 12px;border-radius:10px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--act)"><span style="{{ i14.star }}"></span>Favorite<span style="{{ i14.chevD }}"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ share }}">
|
||||
<div style="position:absolute;inset:0;background:var(--scrim);display:flex;align-items:center;justify-content:center">
|
||||
<div style="width:540px;background:var(--sf);border-radius:24px;padding:24px;box-sizing:border-box;display:flex;flex-direction:column;gap:20px;box-shadow:0 24px 48px rgba(30,0,47,0.14)">
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<div style="{{ shareTile }}"><span style="{{ i20.doc }}"></span></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px"><div style="font:500 19px/1 var(--fd);letter-spacing:-0.02em">Share “Q3 Planning.pdf”</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">2.4 MB · Documents</div></div>
|
||||
<div style="width:32px;height:32px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center"><span style="{{ i16.x }}"></span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:500 16px/1 var(--fd);letter-spacing:-0.02em">Share with people</div>
|
||||
<div style="display:flex;gap:8px"><div style="flex:1;height:42px;border-radius:12px;background:var(--sf2);display:flex;align-items:center;gap:10px;padding:0 12px;color:var(--fg3);font:400 14px/1 var(--ft)"><span style="{{ i18.userPlus }}"></span>Name, email or group</div><div style="height:42px;padding:0 10px 0 14px;border-radius:12px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft)">Can edit<span style="{{ i14.chevD }}"></span></div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;height:44px"><div style="{{ avYou }}">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">You</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">marina@rocha.family</div></div><div style="font:500 13px/1 var(--ft);color:var(--fg3)">Owner</div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;height:44px"><div style="{{ avAna }}">AS</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Ana Souza</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">ana@rocha.family</div></div><div style="height:28px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 12px/1 var(--ft);white-space:nowrap">Can edit<span style="{{ i14.chevD }}"></span></div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;height:44px"><div style="width:32px;height:32px;border-radius:999px;background:var(--sf2);color:var(--fg2);display:flex;align-items:center;justify-content:center;flex:none"><span style="{{ i16.users }}"></span></div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 14px/1 var(--ft)">Design team</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">Group · 6 people</div></div><div style="height:28px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 12px/1 var(--ft);white-space:nowrap">Can view<span style="{{ i14.chevD }}"></span></div></div>
|
||||
</div>
|
||||
<div style="height:1px;background:var(--ln)"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:5px"><div style="font:500 16px/1 var(--fd);letter-spacing:-0.02em">Share link</div><div style="font:400 12px/1 var(--ft);color:var(--fg3)">Anyone with the link can view</div></div><div style="width:44px;height:26px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:20px;height:20px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="border-radius:14px;background:var(--sf2);padding:5px 5px 5px 14px;display:flex;gap:10px;align-items:center;color:var(--fg2)"><span style="{{ i16.link }}"></span><div style="flex:1;min-width:0;font:400 13px/1 ui-monospace,Menlo,monospace;color:var(--fg1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">https://cloud.rocha.family/s/k7Pq2xW</div><div style="height:34px;padding:0 14px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.copy }}"></span>Copy link</div></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div style="height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.eye }}"></span>Can view<span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="height:30px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.calendar }}"></span>Expires Oct 24</div>
|
||||
<div style="height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.lock }}"></span>Add password</div>
|
||||
<div style="height:30px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 12px/1 var(--ft);white-space:nowrap"><span style="{{ i14.download }}"></span>Allow download</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:1px;background:var(--ln)"></div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div style="flex:1;font:400 12px/1.3 var(--ft);color:var(--fg3)">Sends a copy through another app. Link settings don’t apply.</div>
|
||||
<div style="height:38px;padding:0 16px;border-radius:999px;border:1.5px solid var(--sf3);display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.send }}"></span>Send file directly</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":1280,"height":800},"os":{"editor":"enum","options":["mac","windows"],"default":"mac","tsType":"'mac'|'windows'"},"screen":{"editor":"enum","options":["files","filesGrid","photos","favorites","offline","shares","recent","activity","trash","share","settings","settings2"],"default":"files","tsType":"string"},"theme":{"editor":"enum","options":["light","dark"],"default":"light","tsType":"'light'|'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
componentDidMount() {
|
||||
if (!window.NK) this._t = setInterval(() => { if (window.NK) { clearInterval(this._t); this.forceUpdate(); } }, 40);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this._t); }
|
||||
renderVals() {
|
||||
const K = window.NK;
|
||||
if (!K) return { ready: false };
|
||||
const { I, ic, D, row, TH, tile, tiles, avatar } = K;
|
||||
const mac = this.props.os !== 'windows', win = !mac;
|
||||
const sc = this.props.screen || 'files';
|
||||
const theme = this.props.theme === 'dark' ? 'dark' : 'light';
|
||||
const share = sc === 'share', grid = sc === 'filesGrid';
|
||||
const base = (share || grid) ? 'files' : sc;
|
||||
const settings = base.startsWith('settings');
|
||||
const s = {}; ['files','photos','activity','trash','favorites','offline','shares','recent','settings','settings2'].forEach(k => s[k] = base === k);
|
||||
const RS = sel => ({ display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 180px 160px 120px', alignItems: 'center', height: 52, padding: '0 12px', borderBottom: '1px solid var(--ln)', background: sel ? 'var(--acs)' : 'transparent', borderRadius: sel ? 12 : 0 });
|
||||
const mk = (list, fn, o = {}) => list.map((f, i) => { const r = row(f, { tile: 30, r: 9, ico: 16, ...o }); Object.assign(r, fn(f)); r.rs = RS(o.sel === i); return r; });
|
||||
let groups = [], cols = ['Name', 'Size', 'Modified'];
|
||||
const split = f => { const p = (f.meta || '').split(' · '); return { a: p[0], b: p[1] || '' }; };
|
||||
if (base === 'files') groups = [{ rows: mk(D.files, f => ({ a: f.size, b: f.mod, bIcon: !!f.off }), { noSwipe: 1, sel: 3 }) }];
|
||||
if (base === 'favorites') groups = [{ rows: mk(D.favorites, f => ({ a: f.size, b: f.mod, bIcon: !!f.off, fav: true })) }];
|
||||
if (base === 'offline') { cols = ['Name', 'Size', 'Status']; groups = [{ rows: mk(D.offline, f => ({ a: f.size, b: split(f).a, bInline: true })) }]; }
|
||||
if (base === 'shares') { cols = ['Name', 'Shared by', 'Permission']; groups = [{ rows: mk(D.shares, f => ({ a: f.by, b: f.perm })) }]; }
|
||||
if (base === 'recent') { cols = ['Name', 'Activity', 'Location']; groups = D.recent.map(g => ({ label: g.label, rows: mk(g.rows, f => ({ ...split(f), bIcon: !!f.off })) })); }
|
||||
if (base === 'trash') { cols = ['Name', 'Original location', 'Deleted']; groups = [{ rows: mk(D.trash, f => ({ a: f.from, b: f.del })) }]; }
|
||||
groups = groups.map(g => ({ ...g, hasLabel: !!g.label }));
|
||||
const title = settings ? 'Settings' : D.titles[base];
|
||||
const seg = on => ({ width: 34, height: 30, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: on ? 'var(--acs)' : 'transparent', color: on ? 'var(--act)' : 'var(--fg3)' });
|
||||
const counts = { trash: '12', offline: '1', shares: '3' };
|
||||
const navItem = ([label, ico, key]) => ({ label, ico: I[18][ico], icoG: ic(ico, 18, 'var(--fg2)'), on: key === base, off: key !== base, count: counts[key] || '' });
|
||||
return {
|
||||
ready: true, mac, win, s, title, groups, c0: cols[0], c1: cols[1], c2: cols[2], share, notSettings: !settings,
|
||||
sortable: base === 'files' || base === 'favorites',
|
||||
restore: base === 'trash',
|
||||
root: { ...TH[theme], width: 1280, height: 800, borderRadius: mac ? 12 : 8, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column', background: 'var(--bg)', color: 'var(--fg1)', fontFamily: 'var(--ft)', border: '1px solid var(--ln)', boxSizing: 'border-box' },
|
||||
i14: I[14], i16: I[16], i18: I[18], i20: I[20], i24: I[24],
|
||||
showTable: groups.length > 0 && !grid, showGrid: grid,
|
||||
gridRows: D.files.map(f => row(f, { noSwipe: 1, thumbH: 118 })).map(r => ({ ...r, meta: `${r.size} · ${r.mod}` })),
|
||||
segL: seg(!grid), segG: seg(grid),
|
||||
navA: D.pinned.map(navItem), navB: D.menu.map(navItem),
|
||||
setOn: settings, setOff: !settings, setIcoG: ic('settings', 18, 'var(--fg2)'),
|
||||
starSm: ic('star', 16, 'var(--act)'), moreSm: ic('moreH', 16, 'var(--fg3)'),
|
||||
checkA: ic('check', 18, 'var(--act)'), chevG: ic('chevR', 16, 'var(--fg3)'), gripS: ic('grip', 16, 'var(--fg3)'),
|
||||
photoGroups: [{ label: 'September 2026', count: '38 items', tiles: tiles(24, 0, [2, 9, 13, 20]) }, { label: 'August 2026', count: '112 items', tiles: tiles(16, 3, [4, 11]) }],
|
||||
act: D.activity.map(g => ({ label: g.label, items: g.items.map(a => ({ ...a, av: avatar(a.col, 36), tile: tile(a.kind, 30, 9), ico: I[16][K.KIND[a.kind][2]] })) })),
|
||||
tabRows: [...D.pinned.map(([label, ico]) => ({ label, ico: I[18][ico], p: 1 })), ...D.menu.map(([label, ico]) => ({ label, ico: I[18][ico], p: 0 }))].map(t => ({ ...t,
|
||||
pinS: { width: 30, height: 30, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: t.p ? 'var(--acs)' : 'var(--sf2)', color: t.p ? 'var(--act)' : 'var(--fg3)' },
|
||||
pinI: I[14][t.p ? 'pin' : 'pinOff'] })),
|
||||
bioLabel: mac ? 'Lock with Touch ID' : 'Lock with Windows Hello', bioIco: I[18][mac ? 'fingerprint' : 'face'],
|
||||
syncPath: mac ? '~/Noo/rocha.family' : 'C:\\Users\\Marina\\Noo',
|
||||
swipeTitle: mac ? 'Trackpad swipe on a file' : 'Touch swipe on a file',
|
||||
shareTile: tile('pdf', 40, 12), avYou: avatar('#CBA5FD', 32), avAna: avatar('#DA9AC7', 32)
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,433 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<script src="noo-kit.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
|
||||
<style>
|
||||
body{margin:0}
|
||||
a{color:#8D0DE3;text-decoration:none}
|
||||
a:hover{color:#6E08B3}
|
||||
</style>
|
||||
</helmet>
|
||||
<sc-if value="{{ ready }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="{{ root }}">
|
||||
|
||||
<sc-if value="{{ ios }}">
|
||||
<div style="height:54px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 30px 0 46px;position:relative;font:600 17px/1 var(--ft);color:var(--fg1)">
|
||||
<span>9:41</span>
|
||||
<div style="position:absolute;left:50%;top:11px;width:124px;height:36px;margin-left:-62px;border-radius:20px;background:#000000"></div>
|
||||
<div style="display:flex;gap:6px;align-items:center"><span style="{{ i16.signal }}"></span><span style="{{ i16.wifi }}"></span><span style="{{ i24.battery }}"></span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ android }}">
|
||||
<div style="height:40px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 24px;font:500 14px/1 var(--ft);color:var(--fg1)">
|
||||
<span>9:41</span>
|
||||
<div style="display:flex;gap:6px;align-items:center"><span style="{{ i16.wifi }}"></span><span style="{{ i16.signal }}"></span><span style="{{ i16.battery }}"></span></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ aBar }}">
|
||||
<div style="height:64px;flex:none;display:flex;align-items:center;gap:4px;padding:0 8px 0 4px;color:var(--fg1)">
|
||||
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><span style="{{ i24.menu }}"></span></div>
|
||||
<div style="flex:1;font:500 22px/1 var(--fd);letter-spacing:-0.02em">{{ title }}</div>
|
||||
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><span style="{{ i24.search }}"></span></div>
|
||||
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><div style="width:32px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ aSet }}">
|
||||
<div style="height:64px;flex:none;display:flex;align-items:center;gap:4px;padding:0 8px 0 4px;color:var(--fg1)">
|
||||
<div style="width:48px;height:48px;display:flex;align-items:center;justify-content:center"><span style="{{ i24.back }}"></span></div>
|
||||
<div style="flex:1;font:500 22px/1 var(--fd);letter-spacing:-0.02em">Settings</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ iBar }}">
|
||||
<div style="height:44px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 16px;color:var(--act)">
|
||||
<span style="{{ i24.menu }}"></span>
|
||||
<div style="display:flex;gap:18px;align-items:center"><span style="{{ i24.plus }}"></span><div style="width:30px;height:30px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 12px/1 var(--ft)">MR</div></div>
|
||||
</div>
|
||||
<div style="flex:none;padding:2px 16px 12px;font:600 34px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--fg1)">{{ title }}</div>
|
||||
<div style="flex:none;margin:0 16px 12px;height:38px;border-radius:12px;background:var(--sf);display:flex;align-items:center;gap:8px;padding:0 10px;color:var(--fg3);font:400 16px/1 var(--ft)"><span style="{{ i18.search }}"></span>Search</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ iSet }}">
|
||||
<div style="height:44px;flex:none;display:flex;align-items:center;gap:2px;padding:0 10px;color:var(--act);font:400 17px/1 var(--ft)"><span style="{{ i24.chevL }}"></span>Files</div>
|
||||
<div style="flex:none;padding:2px 16px 14px;font:600 34px/0.9 var(--fd);letter-spacing:-0.03em;color:var(--fg1)">Settings</div>
|
||||
</sc-if>
|
||||
|
||||
<div style="flex:1;min-height:0;position:relative;overflow:hidden;display:flex;flex-direction:column">
|
||||
|
||||
<sc-if value="{{ s.files }}">
|
||||
<div style="flex:none;display:flex;align-items:center;gap:8px;padding:4px 12px 12px 12px">
|
||||
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);color:var(--fg1);white-space:nowrap"><span style="{{ i16.sort }}"></span>Name<span style="{{ i14.up }}"></span></div>
|
||||
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);color:var(--fg1);white-space:nowrap"><span style="{{ i16.filter }}"></span><span style="white-space:nowrap">All types</span><span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="flex:1"></div>
|
||||
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px">
|
||||
<div style="{{ segL }}"><span style="{{ i18.list }}"></span></div>
|
||||
<div style="{{ segG }}"><span style="{{ i18.grid }}"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.trash }}">
|
||||
<div style="flex:none;margin:0 12px 16px;padding:14px 16px;border-radius:20px;background:var(--sf);display:flex;gap:12px;align-items:center;color:var(--fg2)">
|
||||
<span style="{{ i20.info }}"></span>
|
||||
<div style="flex:1;font:400 14px/1.3 var(--ft);text-wrap:pretty">Items here are deleted for good after 30 days.</div>
|
||||
<div style="font:600 14px/1 var(--ft);color:var(--dg);white-space:nowrap">Empty trash</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.offline }}">
|
||||
<div style="flex:none;margin:0 12px 16px;padding:18px;border-radius:20px;background:var(--sf);display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px">
|
||||
<div style="display:flex;flex-direction:column;gap:6px"><div style="font:600 32px/0.9 var(--fd);letter-spacing:-0.03em">2.4 GB</div><div style="font:400 14px/1.2 var(--ft);color:var(--fg2)">of your 5 GB offline limit</div></div>
|
||||
<div style="height:36px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sync }}"></span>Sync now</div>
|
||||
</div>
|
||||
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:48%;height:100%;background:var(--ac)"></div></div>
|
||||
<div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">Last synced 2 minutes ago · 1 file needs attention</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.shares }}">
|
||||
<div style="flex:none;margin:0 12px 16px;display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px">
|
||||
<div style="flex:1;height:34px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 14px/1 var(--ft)">With you</div>
|
||||
<div style="flex:1;height:34px;border-radius:999px;color:var(--fg2);display:flex;align-items:center;justify-content:center;font:500 14px/1 var(--ft)">By you</div>
|
||||
<div style="flex:1;height:34px;border-radius:999px;color:var(--fg2);display:flex;align-items:center;justify-content:center;font:500 14px/1 var(--ft)">Links</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showList }}">
|
||||
<div style="display:flex;flex-direction:column;gap:18px;padding:0 12px 16px">
|
||||
<sc-for list="{{ groups }}" as="g" hint-placeholder-count="1">
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<sc-if value="{{ g.hasLabel }}"><div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div></sc-if>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<sc-for list="{{ g.rows }}" as="f" hint-placeholder-count="6">
|
||||
<div style="position:relative;background:var(--dgf)">
|
||||
<sc-if value="{{ f.swiped }}"><div style="position:absolute;right:0;top:0;bottom:0;width:96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#FFFFFF;font:600 12px/1 var(--ft)"><span style="{{ i20.trash }}"></span>Delete</div></sc-if>
|
||||
<div style="{{ f.rs }}">
|
||||
<div style="{{ f.tile }}"><span style="{{ f.ico }}"></span></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px">
|
||||
<div style="font:500 16px/1.2 var(--ft);color:var(--fg1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1.2 var(--ft);color:var(--fg3);white-space:nowrap;overflow:hidden">
|
||||
<sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if>
|
||||
<sc-if value="{{ f.shared }}"><span style="{{ i14.users }}"></span></sc-if>
|
||||
<span style="flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis">{{ f.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<sc-if value="{{ f.fav }}"><span style="{{ starSm }}"></span></sc-if>
|
||||
<div style="width:32px;height:40px;display:flex;align-items:center;justify-content:center"><span style="{{ trail }}"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ showGrid }}">
|
||||
<div style="display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px;padding:0 12px 16px">
|
||||
<sc-for list="{{ gridRows }}" as="f" hint-placeholder-count="6">
|
||||
<div style="background:var(--sf);border-radius:18px;overflow:hidden;display:flex;flex-direction:column">
|
||||
<div style="{{ f.thumb }}"><span style="{{ f.icoL }}"></span></div>
|
||||
<div style="padding:10px 8px 12px 12px;display:flex;flex-direction:column;gap:5px">
|
||||
<div style="display:flex;align-items:center;gap:4px"><div style="flex:1;min-width:0;font:500 14px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div><span style="{{ moreSm }}"></span></div>
|
||||
<div style="display:flex;align-items:center;gap:4px;font:400 12px/1.2 var(--ft);color:var(--fg3);white-space:nowrap"><sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if><sc-if value="{{ f.fav }}"><span style="{{ starXs }}"></span></sc-if>{{ f.size }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.photos }}">
|
||||
<div style="flex:none;display:flex;gap:8px;padding:4px 12px 4px">
|
||||
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 14px/1 var(--ft);white-space:nowrap">All</div>
|
||||
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--sf);display:flex;align-items:center;font:500 14px/1 var(--ft);white-space:nowrap">Photos</div>
|
||||
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--sf);display:flex;align-items:center;font:500 14px/1 var(--ft);white-space:nowrap">Videos</div>
|
||||
<div style="height:34px;padding:0 14px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.camera }}"></span>Camera</div>
|
||||
</div>
|
||||
<sc-for list="{{ photoGroups }}" as="g" hint-placeholder-count="2">
|
||||
<div style="display:flex;justify-content:space-between;align-items:baseline;padding:16px 16px 10px"><div style="font:500 18px/1 var(--fd);letter-spacing:-0.02em">{{ g.label }}</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">{{ g.count }}</div></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:2px">
|
||||
<sc-for list="{{ g.tiles }}" as="t" hint-placeholder-count="9">
|
||||
<div style="{{ t.s }}"><sc-if value="{{ t.vid }}"><div style="position:absolute;right:6px;bottom:6px;height:20px;padding:0 7px 0 5px;border-radius:999px;background:rgba(14,13,11,0.7);color:#FFFFFF;display:flex;align-items:center;gap:3px;font:600 11px/1 var(--ft)"><span style="{{ i14.play }}"></span>{{ t.dur }}</div></sc-if></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-for>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.activity }}">
|
||||
<div style="display:flex;flex-direction:column;gap:18px;padding:0 12px 16px">
|
||||
<sc-for list="{{ act }}" as="g" hint-placeholder-count="2">
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">{{ g.label }}</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<sc-for list="{{ g.items }}" as="a" hint-placeholder-count="3">
|
||||
<div style="display:flex;gap:12px;padding:14px;background:var(--sf);align-items:flex-start">
|
||||
<div style="{{ a.av }}">{{ a.who }}</div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px">
|
||||
<div style="font:400 15px/1.3 var(--ft);color:var(--fg1);text-wrap:pretty"><span style="font-weight:600">{{ a.name }}</span> {{ a.verb }} <span style="font-weight:600">{{ a.obj }}</span> {{ a.tail }}</div>
|
||||
<div style="font:400 13px/1 var(--ft);color:var(--fg3)">{{ a.time }}</div>
|
||||
</div>
|
||||
<div style="{{ a.tile }}"><span style="{{ a.ico }}"></span></div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.settings }}">
|
||||
<div style="display:flex;flex-direction:column;gap:20px;padding:0 12px 16px">
|
||||
<div style="padding:18px;border-radius:20px;background:var(--sf);display:flex;flex-direction:column;gap:16px">
|
||||
<div style="display:flex;gap:14px;align-items:center">
|
||||
<div style="width:56px;height:56px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 20px/1 var(--ft);flex:none">MR</div>
|
||||
<div style="display:flex;flex-direction:column;gap:5px;flex:1;min-width:0"><div style="font:500 20px/1 var(--fd);letter-spacing:-0.02em">Marina Rocha</div><div style="font:400 14px/1 var(--ft);color:var(--fg2)">marina@rocha.family</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;align-items:center;padding:12px 14px;border-radius:14px;background:var(--sf2)">
|
||||
<span style="{{ i18.server }}"></span>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1.1 var(--ft)">cloud.rocha.family</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">Nextcloud 30.0.2</div></div>
|
||||
<div style="height:24px;padding:0 10px;border-radius:999px;background:var(--oks);color:var(--ok);display:flex;align-items:center;font:600 12px/1 var(--ft);white-space:nowrap">Connected</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;font:400 14px/1 var(--ft)"><span style="font-weight:500">Storage</span><span style="color:var(--fg2)">38.2 of 100 GB</span></div>
|
||||
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<div style="flex:1;height:44px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center;gap:8px;font:600 15px/1 var(--ft)"><span style="{{ i16.external }}"></span>Manage on web</div>
|
||||
<div style="flex:1;height:44px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;justify-content:center;gap:8px;font:600 15px/1 var(--ft)"><span style="{{ i16.logout }}"></span>Log out</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Accounts</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:60px;background:var(--sf)"><div style="width:36px;height:36px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 16px/1 var(--ft)">Marina Rocha</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div><span style="{{ checkA }}"></span></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:60px;background:var(--sf)"><div style="width:36px;height:36px;border-radius:999px;background:#85D1BD;color:#0E0D0B;display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 16px/1 var(--ft)">Marina · Work</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">files.acme-studio.com</div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf);color:var(--act);font:600 15px/1 var(--ft)"><div style="width:36px;display:flex;justify-content:center"><span style="{{ i20.plus }}"></span></div>Add account</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Security</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;min-height:60px;box-sizing:border-box;background:var(--sf)"><span style="{{ bioIco }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 16px/1.1 var(--ft)">{{ bioLabel }}</div><div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">Needed every time you open the app</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.timer }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Lock after</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">1 minute<span style="{{ i18.chevR }}"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.settings2 }}">
|
||||
<div style="display:flex;flex-direction:column;gap:20px;padding:0 12px 16px">
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">File sync</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;min-height:60px;box-sizing:border-box;background:var(--sf)"><span style="{{ i20.camera }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 16px/1.1 var(--ft)">Auto-upload photos</div><div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">To /Photos/Camera</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.wifi }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Wi-Fi only</div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.charging }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Only while charging</div><div style="width:48px;height:28px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.sync }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Refresh offline files</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">Every 15 min<span style="{{ i18.chevR }}"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Files cache</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;flex-direction:column;gap:10px;padding:14px 16px;background:var(--sf)"><div style="display:flex;justify-content:space-between;font:400 16px/1 var(--ft)"><span>Cache in use</span><span style="color:var(--fg3);font-size:15px">1.2 of 2 GB</span></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:60%;height:100%;background:var(--ac)"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.database }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Cache limit</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">2 GB<span style="{{ i18.chevR }}"></span></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf);color:var(--dg)"><span style="{{ i20.trash }}"></span><div style="flex:1;font:500 16px/1 var(--ft)">Clear cache</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Appearance</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;height:56px;background:var(--sf)"><div style="flex:1;font:400 16px/1 var(--ft)">Theme</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:30px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft);white-space:nowrap">System</div><div style="height:30px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Light</div><div style="height:30px;padding:0 12px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Dark</div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:0 12px 0 16px;height:56px;background:var(--sf)"><div style="flex:1;font:400 16px/1 var(--ft)">Default view</div><div style="display:flex;padding:3px;border-radius:999px;background:var(--sf2);gap:2px"><div style="height:30px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.list }}"></span>List</div><div style="height:30px;padding:0 12px;display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);color:var(--fg2)"><span style="{{ i14.grid }}"></span>Grid</div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><div style="flex:1;font:400 16px/1 var(--ft)">Show hidden files</div><div style="width:48px;height:28px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ s.settings3 }}">
|
||||
<div style="display:flex;flex-direction:column;gap:20px;padding:0 12px 16px">
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;display:flex;justify-content:space-between;font:600 13px/1 var(--ft);color:var(--fg2)"><span>Tabs</span><span style="font-weight:500;color:var(--fg3)">5 of 5 pinned</span></div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<sc-for list="{{ tabRows }}" as="t" hint-placeholder-count="8">
|
||||
<div style="display:flex;align-items:center;gap:12px;padding:0 10px 0 10px;height:52px;background:var(--sf)">
|
||||
<span style="{{ gripS }}"></span>
|
||||
<span style="{{ t.ico }}"></span>
|
||||
<div style="flex:1;font:400 16px/1 var(--ft)">{{ t.label }}</div>
|
||||
<div style="{{ t.pinS }}"><span style="{{ t.pinI }}"></span></div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="padding:0 8px;font:400 13px/1.3 var(--ft);color:var(--fg3);text-wrap:pretty">Pinned tabs sit in the bottom bar. The rest live in the menu. Drag to reorder.</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="padding:0 8px;font:600 13px/1 var(--ft);color:var(--fg2)">Swipe on a file</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.swipe }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Swipe left</div><div style="height:28px;padding:0 10px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:5px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.trash }}"></span>Delete</div><span style="{{ chevG }}"></span></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.swipe }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Swipe right</div><div style="height:28px;padding:0 10px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:5px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.star }}"></span>Favorite</div><span style="{{ chevG }}"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ fab }}">
|
||||
<div style="position:absolute;right:16px;bottom:16px;height:56px;padding:0 20px 0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:10px;font:600 16px/1 var(--ft)"><span style="{{ i24.plus }}"></span>Upload</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ aNav }}">
|
||||
<div style="flex:none;background:var(--sf);display:flex;flex-direction:column">
|
||||
<div style="display:flex;height:80px;align-items:center">
|
||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<sc-if value="{{ n.on }}"><div style="width:56px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:600 12px/1 var(--ft);color:var(--fg1)">{{ n.label }}</div></sc-if>
|
||||
<sc-if value="{{ n.off }}"><div style="width:56px;height:32px;color:var(--fg2);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:500 12px/1 var(--ft);color:var(--fg2)">{{ n.label }}</div></sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="height:20px;display:flex;align-items:center;justify-content:center"><div style="width:108px;height:4px;border-radius:999px;background:var(--fg3)"></div></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ iNav }}">
|
||||
<div style="flex:none;background:var(--sf);border-top:1px solid var(--ln);display:flex;flex-direction:column">
|
||||
<div style="display:flex;padding-top:8px;height:50px;box-sizing:border-box">
|
||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
|
||||
<sc-if value="{{ n.on }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--act);font:600 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
|
||||
<sc-if value="{{ n.off }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--fg3);font:500 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="height:34px;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;box-sizing:border-box"><div style="width:134px;height:5px;border-radius:999px;background:var(--fg1)"></div></div>
|
||||
</div>
|
||||
</sc-if>
|
||||
<sc-if value="{{ aGesture }}"><div style="height:24px;flex:none;display:flex;align-items:center;justify-content:center"><div style="width:108px;height:4px;border-radius:999px;background:var(--fg3)"></div></div></sc-if>
|
||||
<sc-if value="{{ iGesture }}"><div style="height:34px;flex:none;display:flex;align-items:flex-end;justify-content:center;padding-bottom:8px;box-sizing:border-box"><div style="width:134px;height:5px;border-radius:999px;background:var(--fg1)"></div></div></sc-if>
|
||||
|
||||
<sc-if value="{{ drawer }}">
|
||||
<div style="position:absolute;inset:0;background:var(--scrim)"></div>
|
||||
<div style="position:absolute;left:0;top:0;bottom:0;width:316px;background:var(--sf);border-radius:0 28px 28px 0;display:flex;flex-direction:column;padding:64px 12px 28px;box-sizing:border-box;gap:4px">
|
||||
<div style="display:flex;gap:12px;align-items:center;padding:0 12px 14px">
|
||||
<div style="width:48px;height:48px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 17px/1 var(--ft);flex:none">MR</div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px"><div style="font:500 18px/1 var(--fd);letter-spacing:-0.02em">Marina Rocha</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">cloud.rocha.family</div></div>
|
||||
<div style="width:36px;height:36px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center"><span style="{{ i18.chevD }}"></span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;padding:0 12px 16px">
|
||||
<div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:38%;height:100%;background:var(--ac)"></div></div>
|
||||
<div style="font:400 13px/1 var(--ft);color:var(--fg3)">38.2 GB of 100 GB used</div>
|
||||
</div>
|
||||
<div style="height:1px;background:var(--ln);margin:0 12px 8px"></div>
|
||||
<div style="padding:8px 16px;font:600 13px/1 var(--ft);color:var(--fg2)">More</div>
|
||||
<sc-for list="{{ menuRows }}" as="m" hint-placeholder-count="3">
|
||||
<div style="height:52px;border-radius:999px;padding:0 16px;display:flex;align-items:center;gap:14px;font:500 16px/1 var(--ft)"><span style="{{ m.ico }}"></span><div style="flex:1">{{ m.label }}</div><div style="font:500 14px/1 var(--ft);color:var(--fg3)">{{ m.count }}</div></div>
|
||||
</sc-for>
|
||||
<div style="height:1px;background:var(--ln);margin:8px 12px"></div>
|
||||
<div style="height:52px;border-radius:999px;padding:0 16px;display:flex;align-items:center;gap:14px;font:500 16px/1 var(--ft)"><span style="{{ i24.settings }}"></span>Settings</div>
|
||||
<div style="flex:1"></div>
|
||||
<div style="padding:0 16px;display:flex;align-items:center;gap:8px;font:600 14px/1 var(--ft);color:var(--act)"><span style="{{ i16.pencil }}"></span>Edit tabs</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
<sc-if value="{{ share }}">
|
||||
<div style="position:absolute;inset:0;background:var(--scrim)"></div>
|
||||
<div style="position:absolute;left:0;right:0;bottom:0;background:var(--sf);border-radius:28px 28px 0 0;padding:10px 20px 40px;display:flex;flex-direction:column;gap:22px">
|
||||
<div style="align-self:center;width:36px;height:5px;border-radius:999px;background:var(--sf3)"></div>
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<div style="{{ shareTile }}"><span style="{{ i24.doc }}"></span></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:5px"><div style="font:500 19px/1 var(--fd);letter-spacing:-0.02em">Q3 Planning.pdf</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">2.4 MB · Documents</div></div>
|
||||
<div style="width:36px;height:36px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;justify-content:center"><span style="{{ i18.x }}"></span></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:500 17px/1 var(--fd);letter-spacing:-0.02em">Share with people</div>
|
||||
<div style="height:48px;border-radius:14px;background:var(--sf2);display:flex;align-items:center;gap:10px;padding:0 14px;color:var(--fg3);font:400 15px/1 var(--ft)"><span style="{{ i20.userPlus }}"></span>Name, email or group</div>
|
||||
<div style="display:flex;flex-direction:column;gap:14px;padding-top:4px">
|
||||
<div style="display:flex;align-items:center;gap:12px"><div style="{{ avYou }}">MR</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1 var(--ft)">You</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">marina@rocha.family</div></div><div style="font:500 13px/1 var(--ft);color:var(--fg3);padding-right:4px">Owner</div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px"><div style="{{ avAna }}">AS</div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1 var(--ft)">Ana Souza</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">ana@rocha.family</div></div><div style="height:30px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 13px/1 var(--ft);white-space:nowrap">Can edit<span style="{{ i14.chevD }}"></span></div></div>
|
||||
<div style="display:flex;align-items:center;gap:12px"><div style="width:36px;height:36px;border-radius:999px;background:var(--sf2);color:var(--fg2);display:flex;align-items:center;justify-content:center;flex:none"><span style="{{ i18.users }}"></span></div><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:500 15px/1 var(--ft)">Design team</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">Group · 6 people</div></div><div style="height:30px;padding:0 8px 0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:4px;font:500 13px/1 var(--ft);white-space:nowrap">Can view<span style="{{ i14.chevD }}"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:1px;background:var(--ln)"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center"><div style="display:flex;flex-direction:column;gap:5px"><div style="font:500 17px/1 var(--fd);letter-spacing:-0.02em">Share link</div><div style="font:400 13px/1 var(--ft);color:var(--fg3)">Anyone with the link can view</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="border-radius:16px;background:var(--sf2);padding:6px 6px 6px 14px;display:flex;gap:10px;align-items:center;color:var(--fg2)"><span style="{{ i18.link }}"></span><div style="flex:1;min-width:0;font:400 14px/1 ui-monospace,Menlo,monospace;color:var(--fg1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">cloud.rocha.family/s/k7Pq2xW</div><div style="height:40px;padding:0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:6px;font:600 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.copy }}"></span>Copy link</div></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.eye }}"></span>Can view<span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.calendar }}"></span>Expires Oct 24</div>
|
||||
<div style="height:32px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 13px/1 var(--ft);white-space:nowrap"><span style="{{ i14.lock }}"></span>Add password</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div style="height:52px;border-radius:999px;border:1.5px solid var(--sf3);display:flex;align-items:center;justify-content:center;gap:10px;font:600 16px/1 var(--ft)"><span style="{{ i20.send }}"></span>Send file directly</div>
|
||||
<div style="font:400 13px/1.3 var(--ft);color:var(--fg3);text-align:center">Sends a copy through another app. Link settings don’t apply.</div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
|
||||
</div>
|
||||
</sc-if>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":390,"height":844},"platform":{"editor":"enum","options":["ios","android"],"default":"ios","tsType":"'ios'|'android'"},"screen":{"editor":"enum","options":["files","filesGrid","photos","favorites","offline","shares","recent","activity","trash","drawer","share","settings","settings2","settings3"],"default":"files","tsType":"string"},"theme":{"editor":"enum","options":["light","dark"],"default":"light","tsType":"'light'|'dark'"}}">
|
||||
class Component extends DCLogic {
|
||||
componentDidMount() {
|
||||
if (!window.NK) this._t = setInterval(() => { if (window.NK) { clearInterval(this._t); this.forceUpdate(); } }, 40);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this._t); }
|
||||
renderVals() {
|
||||
const K = window.NK;
|
||||
if (!K) return { ready: false };
|
||||
const { I, ic, D, row, TH, tile, tiles, avatar } = K;
|
||||
const ios = this.props.platform !== 'android', android = !ios;
|
||||
const sc = this.props.screen || 'files';
|
||||
const theme = this.props.theme === 'dark' ? 'dark' : 'light';
|
||||
const drawer = sc === 'drawer', share = sc === 'share';
|
||||
const grid = sc === 'filesGrid';
|
||||
const base = (drawer || share || grid) ? 'files' : sc;
|
||||
const settings = base.startsWith('settings');
|
||||
const s = {}; ['files','photos','activity','trash','favorites','offline','shares','recent','settings','settings2','settings3'].forEach(k => s[k] = base === k);
|
||||
const ROW = { display: 'flex', alignItems: 'center', gap: 14, padding: '0 6px 0 14px', height: 64, background: 'var(--sf)', position: 'relative' };
|
||||
const mk = (list, o = {}) => list.map(f => { const r = row(f, o); r.rs = { ...ROW, transform: r.swiped ? 'translateX(-96px)' : 'none' }; return r; });
|
||||
const noSwipe = drawer || share;
|
||||
let groups = [], trail = ic(ios ? 'moreH' : 'moreV', 20, 'var(--fg3)');
|
||||
if (base === 'files') groups = [{ rows: mk(D.files, { noSwipe }) }];
|
||||
if (base === 'favorites') { groups = [{ rows: mk(D.favorites, { noFav: 1 }) }]; trail = ic('star', 20, 'var(--act)'); }
|
||||
if (base === 'offline') groups = [{ rows: mk(D.offline) }];
|
||||
if (base === 'shares') groups = [{ rows: mk(D.shares) }];
|
||||
if (base === 'recent') groups = D.recent.map(g => ({ label: g.label, rows: mk(g.rows) }));
|
||||
if (base === 'trash') { groups = [{ rows: mk(D.trash) }]; trail = ic('restore', 20, 'var(--act)'); }
|
||||
groups = groups.map(g => ({ ...g, hasLabel: !!g.label }));
|
||||
const title = settings ? 'Settings' : D.titles[base];
|
||||
const seg = on => ({ width: 36, height: 28, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: on ? 'var(--acs)' : 'transparent', color: on ? 'var(--act)' : 'var(--fg3)' });
|
||||
return {
|
||||
ready: true, ios, android, s, title, groups, trail, drawer, share,
|
||||
root: { ...TH[theme], width: 390, height: 844, borderRadius: ios ? 48 : 36, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column', background: 'var(--bg)', color: 'var(--fg1)', fontFamily: 'var(--ft)' },
|
||||
i14: I[14], i16: I[16], i18: I[18], i20: I[20], i24: I[24],
|
||||
aBar: android && !settings, aSet: android && settings, iBar: ios && !settings, iSet: ios && settings,
|
||||
aNav: android && !settings, iNav: ios && !settings, aGesture: android && settings, iGesture: ios && settings,
|
||||
showList: groups.length > 0 && !grid, showGrid: grid,
|
||||
gridRows: D.files.slice(0, 8).map(f => row(f, { noSwipe: 1 })),
|
||||
segL: seg(!grid), segG: seg(grid),
|
||||
fab: android && (base === 'files' || base === 'photos'),
|
||||
starSm: ic('star', 16, 'var(--act)'), starXs: ic('star', 12, 'var(--act)'), moreSm: ic(ios ? 'moreH' : 'moreV', 16, 'var(--fg3)'),
|
||||
checkA: ic('check', 20, 'var(--act)'), chevG: ic('chevR', 18, 'var(--fg3)'), gripS: ic('grip', 18, 'var(--fg3)'),
|
||||
photoGroups: [{ label: 'September 2026', count: '38 items', tiles: tiles(18, 0, [2, 9, 13]) }, { label: 'August 2026', count: '112 items', tiles: tiles(9, 3, [4]) }],
|
||||
act: D.activity.map(g => ({ label: g.label, items: g.items.map(a => ({ ...a, av: avatar(a.col, 36), tile: tile(a.kind, 32, 10), ico: I[16][K.KIND[a.kind][2]] })) })),
|
||||
nav: D.pinned.map(([label, ico, key]) => ({ label, ico: I[24][ico], on: key === base, off: key !== base })),
|
||||
menuRows: D.menu.map(([label, ico, key, count]) => ({ label, ico: I[24][ico], count })),
|
||||
tabRows: [...D.pinned.map(([label, ico]) => ({ label, ico: I[20][ico], p: 1 })), ...D.menu.map(([label, ico]) => ({ label, ico: I[20][ico], p: 0 }))].map(t => ({ ...t,
|
||||
pinS: { width: 36, height: 36, borderRadius: 999, display: 'flex', alignItems: 'center', justifyContent: 'center', background: t.p ? 'var(--acs)' : 'var(--sf2)', color: t.p ? 'var(--act)' : 'var(--fg3)' },
|
||||
pinI: I[16][t.p ? 'pin' : 'pinOff'] })),
|
||||
bioLabel: ios ? 'Lock with Face ID' : 'Lock with fingerprint', bioIco: I[20][ios ? 'face' : 'fingerprint'],
|
||||
shareTile: tile('pdf', 44, 12), avYou: { ...avatar('#CBA5FD', 36) }, avAna: avatar('#DA9AC7', 36)
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<script src="noo-kit.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
|
||||
<style>
|
||||
body{margin:0;background:#F3EFE6}
|
||||
a{color:#8D0DE3;text-decoration:none}
|
||||
a:hover{color:#6E08B3}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="max-width:1440px;margin:0 auto;padding:64px 40px 96px;display:flex;flex-direction:column;gap:48px;font-family:'Instrument Sans',system-ui,sans-serif;color:#0E0D0B">
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
<div style="font:600 64px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">Noo design system</div>
|
||||
<div style="font:400 18px/1.35 'Instrument Sans',system-ui,sans-serif;color:#5A5852;max-width:760px;text-wrap:pretty">Every component is shown in light and dark. The full rules for code are in <a href="DESIGN_SYSTEM.md">DESIGN_SYSTEM.md</a>. You can see every screen in <a href="Noo Screens.dc.html">Noo Screens</a>.</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ ready }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div style="font:500 28px/1 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.02em">Type</div>
|
||||
<div style="background:#FFFFFF;border-radius:20px;padding:8px 24px">
|
||||
<sc-for list="{{ type }}" as="t" hint-placeholder-count="9">
|
||||
<div style="display:grid;grid-template-columns:200px minmax(0,1fr);gap:24px;align-items:center;padding:16px 0;border-bottom:1px solid #EAE8E2">
|
||||
<div style="font:400 13px/1.3 ui-monospace,Menlo,monospace;color:#7C7A72">{{ t.spec }}</div>
|
||||
<div style="{{ t.s }}">{{ t.sample }}</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,600px),1fr));gap:24px">
|
||||
<sc-for list="{{ themes }}" as="th" hint-placeholder-count="2">
|
||||
<div style="{{ th.vars }}">
|
||||
<div style="display:flex;flex-direction:column;gap:28px;padding:28px;border-radius:28px;background:var(--bg);color:var(--fg1);font-family:var(--ft)">
|
||||
<div style="font:600 32px/0.9 var(--fd);letter-spacing:-0.03em">{{ th.label }}</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Color</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px">
|
||||
<sc-for list="{{ th.swatches }}" as="c" hint-placeholder-count="16">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;padding:6px;border-radius:14px;background:var(--sf)">
|
||||
<div style="{{ c.s }}"></div>
|
||||
<div style="padding:0 4px 4px;display:flex;flex-direction:column;gap:3px"><div style="font:600 12px/1 var(--ft)">{{ c.name }}</div><div style="font:400 11px/1 ui-monospace,Menlo,monospace;color:var(--fg3)">{{ c.hex }}</div></div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">File-type tiles</div>
|
||||
<div style="display:flex;gap:14px;flex-wrap:wrap">
|
||||
<sc-for list="{{ kinds }}" as="k" hint-placeholder-count="7">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:6px"><div style="{{ k.tile }}"><span style="{{ k.ico }}"></span></div><div style="font:500 12px/1 var(--ft);color:var(--fg2)">{{ k.name }}</div></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Buttons</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<div style="height:44px;padding:0 20px 0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i18.plus }}"></span>Primary</div>
|
||||
<div style="height:44px;padding:0 20px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sync }}"></span>Tonal</div>
|
||||
<div style="height:44px;padding:0 20px;border-radius:999px;background:var(--sf2);display:flex;align-items:center;font:600 15px/1 var(--ft);white-space:nowrap">Secondary</div>
|
||||
<div style="height:44px;padding:0 20px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i16.logout }}"></span>Danger</div>
|
||||
<div style="height:44px;padding:0 20px;border-radius:999px;border:1.5px solid var(--sf3);display:flex;align-items:center;gap:8px;font:600 15px/1 var(--ft);white-space:nowrap"><span style="{{ i16.send }}"></span>Outline</div>
|
||||
<div style="height:56px;padding:0 20px 0 16px;border-radius:999px;background:var(--ac);color:#FFFFFF;display:flex;align-items:center;gap:10px;font:600 16px/1 var(--ft);white-space:nowrap"><span style="{{ i24.plus }}"></span>FAB · Android</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Chips, segmented, toggles, search</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--sf);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);white-space:nowrap"><span style="{{ i16.sort }}"></span>Name<span style="{{ i14.up }}"></span></div>
|
||||
<div style="height:34px;padding:0 12px;border-radius:999px;border:1px solid var(--ln);display:flex;align-items:center;gap:6px;font:500 14px/1 var(--ft);white-space:nowrap">Modified<span style="{{ i14.chevD }}"></span></div>
|
||||
<div style="height:34px;padding:0 12px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;gap:6px;font:600 14px/1 var(--ft);white-space:nowrap">Available offline<span style="{{ i14.x }}"></span></div>
|
||||
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px"><div style="width:36px;height:28px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center"><span style="{{ i18.list }}"></span></div><div style="width:36px;height:28px;color:var(--fg3);display:flex;align-items:center;justify-content:center"><span style="{{ i18.grid }}"></span></div></div>
|
||||
<div style="display:flex;padding:3px;border-radius:999px;background:var(--sf);gap:2px"><div style="height:30px;padding:0 14px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;font:600 13px/1 var(--ft)">With you</div><div style="height:30px;padding:0 14px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">By you</div><div style="height:30px;padding:0 14px;display:flex;align-items:center;font:500 13px/1 var(--ft);color:var(--fg2)">Links</div></div>
|
||||
<div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div>
|
||||
<div style="width:48px;height:28px;border-radius:999px;background:var(--sf3);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-start"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div>
|
||||
</div>
|
||||
<div style="height:38px;border-radius:12px;background:var(--sf);display:flex;align-items:center;gap:8px;padding:0 12px;color:var(--fg3);font:400 16px/1 var(--ft)"><span style="{{ i18.search }}"></span>Search</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Grouped list · file rows · swipe</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<sc-for list="{{ rows }}" as="f" hint-placeholder-count="4">
|
||||
<div style="position:relative;background:var(--dgf)">
|
||||
<sc-if value="{{ f.swiped }}"><div style="position:absolute;right:0;top:0;bottom:0;width:96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;color:#FFFFFF;font:600 12px/1 var(--ft)"><span style="{{ i20.trash }}"></span>Delete</div></sc-if>
|
||||
<div style="{{ f.rs }}">
|
||||
<div style="{{ f.tile }}"><span style="{{ f.ico }}"></span></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px">
|
||||
<div style="font:500 16px/1.2 var(--ft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ f.name }}</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1.2 var(--ft);color:var(--fg3);white-space:nowrap"><sc-if value="{{ f.hasBadge }}"><span style="{{ f.badge }}"></span></sc-if><sc-if value="{{ f.shared }}"><span style="{{ i14.users }}"></span></sc-if>{{ f.meta }}</div>
|
||||
</div>
|
||||
<sc-if value="{{ f.fav }}"><span style="{{ starSm }}"></span></sc-if>
|
||||
<div style="width:32px;display:flex;justify-content:center"><span style="{{ moreT }}"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Settings rows · progress · badges · avatars</div>
|
||||
<div style="display:flex;flex-direction:column;gap:1px;background:var(--ln);border-radius:20px;overflow:hidden">
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:12px 16px;min-height:60px;box-sizing:border-box;background:var(--sf)"><span style="{{ i20.fingerprint }}"></span><div style="flex:1;display:flex;flex-direction:column;gap:4px"><div style="font:400 16px/1.1 var(--ft)">Toggle row</div><div style="font:400 13px/1.2 var(--ft);color:var(--fg3)">With a subtitle</div></div><div style="width:48px;height:28px;border-radius:999px;background:var(--ac);padding:3px;box-sizing:border-box;display:flex;justify-content:flex-end;flex:none"><div style="width:22px;height:22px;border-radius:999px;background:#FFFFFF"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf)"><span style="{{ i20.timer }}"></span><div style="flex:1;font:400 16px/1 var(--ft)">Value row</div><div style="display:flex;align-items:center;gap:4px;font:400 15px/1 var(--ft);color:var(--fg3)">1 minute<span style="{{ i18.chevR }}"></span></div></div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;padding:14px 16px;background:var(--sf)"><div style="display:flex;justify-content:space-between;font:400 16px/1 var(--ft)"><span>Progress row</span><span style="color:var(--fg3);font-size:15px">1.2 of 2 GB</span></div><div style="height:6px;border-radius:999px;background:var(--sf3);overflow:hidden"><div style="width:60%;height:100%;background:var(--ac)"></div></div></div>
|
||||
<div style="display:flex;align-items:center;gap:14px;padding:0 16px;height:52px;background:var(--sf);color:var(--dg)"><span style="{{ i20.trash }}"></span><div style="flex:1;font:500 16px/1 var(--ft)">Destructive row</div></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<div style="height:24px;padding:0 10px;border-radius:999px;background:var(--oks);color:var(--ok);display:flex;align-items:center;font:600 12px/1 var(--ft)">Connected</div>
|
||||
<div style="height:24px;padding:0 10px;border-radius:999px;background:var(--dgs);color:var(--dg);display:flex;align-items:center;font:600 12px/1 var(--ft)">Couldn’t sync</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1 var(--ft);color:var(--fg3)"><span style="{{ bOk }}"></span>Synced</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1 var(--ft);color:var(--fg3)"><span style="{{ bSync }}"></span>Syncing</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;font:400 13px/1 var(--ft);color:var(--fg3)"><span style="{{ bErr }}"></span>Error</div>
|
||||
<div style="flex:1"></div>
|
||||
<div style="width:36px;height:36px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center;font:600 13px/1 var(--ft)">MR</div>
|
||||
<div style="{{ avA }}">AS</div>
|
||||
<div style="{{ avB }}">LP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:12px">
|
||||
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Navigation · iOS tab bar / Android navigation bar / desktop sidebar</div>
|
||||
<div style="display:flex;background:var(--sf);border-radius:20px;padding:10px 0">
|
||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
|
||||
<sc-if value="{{ n.on }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--act);font:600 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
|
||||
<sc-if value="{{ n.off }}"><div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;color:var(--fg3);font:500 10px/1 var(--ft)"><span style="{{ n.ico }}"></span>{{ n.label }}</div></sc-if>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:flex;background:var(--sf);border-radius:20px;padding:14px 0">
|
||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
|
||||
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px">
|
||||
<sc-if value="{{ n.on }}"><div style="width:56px;height:32px;border-radius:999px;background:var(--acs);color:var(--act);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:600 12px/1 var(--ft)">{{ n.label }}</div></sc-if>
|
||||
<sc-if value="{{ n.off }}"><div style="width:56px;height:32px;color:var(--fg2);display:flex;align-items:center;justify-content:center"><span style="{{ n.ico }}"></span></div><div style="font:500 12px/1 var(--ft);color:var(--fg2)">{{ n.label }}</div></sc-if>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
<div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;background:var(--acs);color:var(--act);font:600 14px/1 var(--ft);width:220px;box-sizing:border-box"><span style="{{ i18.folder }}"></span>Files</div>
|
||||
<div style="height:38px;border-radius:12px;padding:0 12px;display:flex;align-items:center;gap:12px;font:500 14px/1 var(--ft);width:220px;box-sizing:border-box"><span style="{{ i18.trash }}"></span><div style="flex:1">Trash</div><div style="font:500 12px/1 var(--ft);color:var(--fg3)">12</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script>
|
||||
class Component extends DCLogic {
|
||||
componentDidMount() {
|
||||
if (!window.NK) this._t = setInterval(() => { if (window.NK) { clearInterval(this._t); this.forceUpdate(); } }, 40);
|
||||
}
|
||||
componentWillUnmount() { clearInterval(this._t); }
|
||||
renderVals() {
|
||||
const K = window.NK;
|
||||
if (!K) return { ready: false };
|
||||
const { I, ic, D, row, TH, tile, avatar } = K;
|
||||
const FD = "'Schibsted Grotesk',system-ui,sans-serif", FT = "'Instrument Sans',system-ui,sans-serif";
|
||||
const t = (spec, sample, s) => ({ spec, sample, s: { ...s, color: '#0E0D0B' } });
|
||||
const names = [['bg','--bg'],['surface','--sf'],['surface-2','--sf2'],['surface-3','--sf3'],['line','--ln'],['fg-1','--fg1'],['fg-2','--fg2'],['fg-3','--fg3'],['accent','--ac'],['accent-text','--act'],['accent-soft','--acs'],['danger','--dg'],['danger-soft','--dgs'],['success','--ok'],['success-soft','--oks'],['warning','--wn'],['warning-soft','--wns'],['info','--in'],['info-soft','--ins']];
|
||||
const ROW = { display: 'flex', alignItems: 'center', gap: 14, padding: '0 6px 0 14px', height: 64, background: 'var(--sf)', position: 'relative' };
|
||||
const demo = [D.files[2], D.files[3], D.offline[2], D.offline[4], { ...D.files[4] }].map(f => { const r = row(f); r.rs = { ...ROW, transform: r.swiped ? 'translateX(-96px)' : 'none' }; return r; });
|
||||
return {
|
||||
ready: true,
|
||||
i14: I[14], i16: I[16], i18: I[18], i20: I[20], i24: I[24],
|
||||
type: [
|
||||
t('Display 600 · 34/0.9 · −3%', 'Large title', { font: `600 34px/0.9 ${FD}`, letterSpacing: '-0.03em' }),
|
||||
t('Display 600 · 32/0.9 · −3%', '2.4 GB', { font: `600 32px/0.9 ${FD}`, letterSpacing: '-0.03em' }),
|
||||
t('Display 500 · 22/1 · −2%', 'Top bar title', { font: `500 22px/1 ${FD}`, letterSpacing: '-0.02em' }),
|
||||
t('Display 500 · 18/1 · −2%', 'Card title · September 2026', { font: `500 18px/1 ${FD}`, letterSpacing: '-0.02em' }),
|
||||
t('Text 500 · 16/1.2', 'Q3 Planning.pdf', { font: `500 16px/1.2 ${FT}` }),
|
||||
t('Text 400 · 15/1.3', 'Ana Souza shared Q3 Planning.pdf with you', { font: `400 15px/1.3 ${FT}` }),
|
||||
t('Text 600 · 13/1', 'Section label', { font: `600 13px/1 ${FT}` }),
|
||||
t('Text 400 · 13/1.2 · fg-3', '2.4 MB · Yesterday', { font: `400 13px/1.2 ${FT}`, opacity: 0.62 }),
|
||||
t('Mono 400 · 14', 'cloud.rocha.family/s/k7Pq2xW', { font: '400 14px/1 ui-monospace,Menlo,monospace' })
|
||||
],
|
||||
themes: ['light', 'dark'].map(id => ({ label: id === 'light' ? 'Light' : 'Dark', vars: TH[id],
|
||||
swatches: names.map(([name, v]) => ({ name, hex: TH[id][v], s: { height: 44, borderRadius: 10, background: `var(${v})`, boxShadow: 'inset 0 0 0 1px var(--ln)' } })) })),
|
||||
kinds: Object.keys(K.KIND).map(k => ({ name: k, tile: tile(k, 40, 12), ico: I[20][K.KIND[k][2]] })),
|
||||
rows: demo, starSm: ic('star', 16, 'var(--act)'), moreT: ic('moreH', 20, 'var(--fg3)'),
|
||||
bOk: ic('ok', 14, 'var(--ok)'), bSync: ic('sync', 14, 'var(--act)'), bErr: ic('alert', 14, 'var(--dg)'),
|
||||
avA: avatar('#DA9AC7', 36), avB: avatar('#8DCDE2', 36),
|
||||
nav: D.pinned.map(([label, ico], i) => ({ label, ico: I[24][ico], on: i === 0, off: i !== 0 }))
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<meta name="design_doc_mode" content="canvas">
|
||||
<script src="noo-kit.js"></script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600&display=swap">
|
||||
<style>
|
||||
body{margin:0;background:#EAE8E2}
|
||||
a{color:#8D0DE3;text-decoration:none}
|
||||
a:hover{color:#6E08B3}
|
||||
</style>
|
||||
</helmet>
|
||||
<div style="padding:96px;display:flex;flex-direction:column;gap:120px;font-family:'Instrument Sans',system-ui,sans-serif;color:#0E0D0B;width:max-content">
|
||||
<div style="display:flex;flex-direction:column;gap:20px;max-width:1100px">
|
||||
<div style="font:600 96px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">Noo</div>
|
||||
<div style="font:400 28px/1.3 'Instrument Sans',system-ui,sans-serif;color:#5A5852;text-wrap:pretty">A Nextcloud client for iOS, Android, macOS and Windows. One brand language, navigation adapted per platform. Light and dark shown for every screen.</div>
|
||||
<div style="display:flex;gap:12px;font:600 20px/1 'Instrument Sans',system-ui,sans-serif"><a href="Noo Design System.dc.html" style="height:52px;padding:0 24px;border-radius:999px;background:#8D0DE3;color:#FFFFFF;display:flex;align-items:center;white-space:nowrap">Open the design system</a><a href="DESIGN_SYSTEM.md" style="height:52px;padding:0 24px;border-radius:999px;background:#FFFFFF;display:flex;align-items:center;white-space:nowrap">DESIGN_SYSTEM.md</a></div>
|
||||
</div>
|
||||
|
||||
<sc-for list="{{ mobile }}" as="p" hint-placeholder-count="2">
|
||||
<section style="display:flex;flex-direction:column;gap:40px">
|
||||
<div style="display:flex;align-items:baseline;gap:24px;border-bottom:2px solid #0E0D0B;padding-bottom:20px"><div style="font:600 72px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">{{ p.name }}</div><div style="font:400 24px/1.2 'Instrument Sans',system-ui,sans-serif;color:#5A5852;white-space:nowrap">{{ p.note }}</div></div>
|
||||
<div style="display:flex;gap:120px">
|
||||
<sc-for list="{{ p.themes }}" as="t" hint-placeholder-count="2">
|
||||
<div style="display:flex;flex-direction:column;gap:32px">
|
||||
<div style="font:500 36px/1 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.02em">{{ t.label }}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(7,390px);gap:72px 48px">
|
||||
<sc-for list="{{ screensM }}" as="sc" hint-placeholder-count="14">
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;gap:10px;align-items:baseline;font:500 18px/1 'Instrument Sans',system-ui,sans-serif"><span style="color:#7C7A72">{{ sc.n }}</span><span style="white-space:nowrap">{{ sc.label }}</span></div>
|
||||
<div style="width:390px;height:844px;border-radius:48px;box-shadow:0 0 0 1px rgba(14,13,11,0.08),0 24px 48px rgba(30,0,47,0.10)">
|
||||
<dc-import name="Mobile Screen" platform="{{ p.id }}" screen="{{ sc.id }}" theme="{{ t.id }}" hint-size="390px,844px"></dc-import>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
|
||||
<sc-for list="{{ desktop }}" as="p" hint-placeholder-count="2">
|
||||
<section style="display:flex;flex-direction:column;gap:40px">
|
||||
<div style="display:flex;align-items:baseline;gap:24px;border-bottom:2px solid #0E0D0B;padding-bottom:20px"><div style="font:600 72px/0.9 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.03em">{{ p.name }}</div><div style="font:400 24px/1.2 'Instrument Sans',system-ui,sans-serif;color:#5A5852;white-space:nowrap">{{ p.note }}</div></div>
|
||||
<div style="display:flex;gap:160px">
|
||||
<sc-for list="{{ p.themes }}" as="t" hint-placeholder-count="2">
|
||||
<div style="display:flex;flex-direction:column;gap:32px">
|
||||
<div style="font:500 36px/1 'Schibsted Grotesk',system-ui,sans-serif;letter-spacing:-0.02em">{{ t.label }}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1280px);gap:72px 64px">
|
||||
<sc-for list="{{ screensD }}" as="sc" hint-placeholder-count="12">
|
||||
<div style="display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;gap:10px;align-items:baseline;font:500 20px/1 'Instrument Sans',system-ui,sans-serif"><span style="color:#7C7A72">{{ sc.n }}</span><span style="white-space:nowrap">{{ sc.label }}</span></div>
|
||||
<div style="width:1280px;height:800px;border-radius:12px;box-shadow:0 24px 48px rgba(30,0,47,0.12)">
|
||||
<dc-import name="Desktop Screen" os="{{ p.id }}" screen="{{ sc.id }}" theme="{{ t.id }}" hint-size="1280px,800px"></dc-import>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</section>
|
||||
</sc-for>
|
||||
</div>
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"platforms":{"editor":"enum","options":["all","ios","android","mac","windows"],"default":"all","tsType":"string"},"themes":{"editor":"enum","options":["both","light","dark"],"default":"both","tsType":"string"}}">
|
||||
class Component extends DCLogic {
|
||||
renderVals() {
|
||||
const pf = this.props.platforms || 'all';
|
||||
const th = this.props.themes || 'both';
|
||||
const themes = [{ id: 'light', label: 'Light' }, { id: 'dark', label: 'Dark' }].filter(t => th === 'both' || th === t.id);
|
||||
const M = [['files','Files · list'],['filesGrid','Files · grid'],['photos','Photos'],['favorites','Favorites'],['offline','Offline'],['shares','Shares'],['recent','Recent'],['activity','Activity'],['trash','Trash'],['drawer','Menu (unpinned tabs)'],['share','Share sheet'],['settings','Settings · account & security'],['settings2','Settings · sync, cache, appearance'],['settings3','Settings · tabs & swipe']];
|
||||
const Dk = [['files','Files · list'],['filesGrid','Files · grid'],['photos','Photos'],['favorites','Favorites'],['offline','Offline'],['shares','Shares'],['recent','Recent'],['activity','Activity'],['trash','Trash'],['share','Share dialog'],['settings','Settings · account, security, sync'],['settings2','Settings · cache, appearance, tabs, swipe']];
|
||||
const num = a => a.map(([id, label], i) => ({ id, label, n: String(i + 1).padStart(2, '0') }));
|
||||
const keep = id => pf === 'all' || pf === id;
|
||||
return {
|
||||
screensM: num(M), screensD: num(Dk),
|
||||
mobile: [{ id: 'ios', name: 'iOS', note: 'Large titles, tab bar, “+” upload in the nav bar.', themes }, { id: 'android', name: 'Android', note: 'Top app bar, navigation bar with pill indicator, Upload FAB.', themes }].filter(p => keep(p.id)),
|
||||
desktop: [{ id: 'mac', name: 'macOS', note: 'Traffic lights in the sidebar, back/forward in the toolbar.', themes }, { id: 'windows', name: 'Windows', note: 'Title bar with caption buttons, same sidebar and toolbar.', themes }].filter(p => keep(p.id))
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -26,9 +26,18 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
||||
Chrome-autofill (Android's own system Autofill framework, e.g. a
|
||||
password manager, may still work in the WebView; Chrome's own
|
||||
saved-password autofill specifically cannot, since that's Chrome-only).
|
||||
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
||||
`LoginWebViewView` also clears cookies (`WebViewCookieManager().
|
||||
clearCookies()`) before every load, not just once - Android's WebView
|
||||
`CookieManager` is a single store shared/persisted across every WebView
|
||||
instance in the app process, not scoped per-controller, so without this
|
||||
a second/"Add Account" login silently reuses whichever account's
|
||||
Nextcloud session cookie is already there instead of prompting for
|
||||
credentials (the same class of bug the Custom-Tab-reuse issue above
|
||||
was, just recurring one layer down once everything moved to the owned
|
||||
WebView).
|
||||
3. `SessionController` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
||||
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
||||
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
||||
`session_controller.dart`) until it gets a 200 with `server`/`loginName`/
|
||||
`appPassword`, a non-404 error, or a 10-minute timeout. A single dropped
|
||||
connection mid-poll (`http.ClientException`) is swallowed and retried on
|
||||
the next tick rather than aborting the whole flow.
|
||||
@@ -53,7 +62,7 @@ below.
|
||||
[`NextcloudService`](../../lib/services/nextcloud_service.dart) is the
|
||||
client for an authenticated session — constructed with `serverUrl` +
|
||||
`username` + the app password, one instance per login (held as
|
||||
`ServerProvider.service`, recreated on every login/switch/logout). It's
|
||||
`SessionController.service`, recreated on every login/switch/logout). It's
|
||||
effectively stateless per-instance (three final fields, headers rebuilt per
|
||||
request), which is what makes it trivial to have one saved per account
|
||||
rather than needing a rewrite for multi-account support.
|
||||
@@ -64,21 +73,107 @@ rather than needing a rewrite for multi-account support.
|
||||
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
|
||||
file exist because WebDAV responses use RFC 1123 dates and either bare
|
||||
paths or full URLs for `href`; reuse them rather than re-deriving.
|
||||
`creationdate` specifically needs `_parseDavCreationDate`, not
|
||||
`_parseDavDate` directly - Nextcloud has no real per-file creation-time
|
||||
tracking for most setups, so that property routinely comes back as a
|
||||
placeholder Unix-epoch date rather than being omitted, which
|
||||
`_parseDavDate` alone parses "successfully" into a real (if bogus)
|
||||
January 1970 `DateTime`. `_parseDavCreationDate` treats that placeholder
|
||||
as absent instead, so `NextcloudItem.dateCreated` falls back to
|
||||
`lastModified` (its constructor's default) the same as it would for a
|
||||
missing/unparseable value.
|
||||
- **Everything else** (shares, activity, trash, favorites, quota, user info,
|
||||
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
|
||||
in, with the `OCS-APIRequest: true` header required on every OCS call.
|
||||
*Toggling* a favorite is OCS; *listing* every favorite (`fetchFavorites`,
|
||||
for the Favorites tab) is WebDAV instead - the same `SEARCH` mechanism
|
||||
`fetchAllMedia`/`fetchRecentFiles` use, filtered by `oc:favorite` instead
|
||||
of mimetype/date. Favorites is a real tab
|
||||
(`views/favorites_view.dart`/`FavoritesController.items`/
|
||||
`fetchAll`/`_allFavorites`), not a filter toggle scoped to
|
||||
whatever folder the Files tab happens to be browsing (that's what it
|
||||
used to be - see the note below on why that didn't work). It shares
|
||||
Files' own sort/hidden/storage-scope/grid-list display prefs
|
||||
(`applyFilesDisplayPrefs`, also used by the Move/Copy destination
|
||||
picker) rather than a separate parallel settings dimension. Tapping a
|
||||
favorited folder switches to the Files tab, navigated there
|
||||
(`FilesController.navigateToAbsoluteFolder` +
|
||||
`SettingsController.requestTab`); a favorited file opens directly from
|
||||
the Favorites tab itself. `ItemOperations.deleteItem`/`renameItem`/move/
|
||||
copy all re-sync `FavoritesController._allFavorites` afterward via
|
||||
`FavoritesController.syncIfLoaded` (only once Favorites has actually been
|
||||
opened this session, tracked by `_everFetched`, so those actions don't
|
||||
pay for an extra request on every edit for an account that's never
|
||||
visited the tab) since none of them know how to patch `_allFavorites` in
|
||||
place the way `ItemOperations.toggleItemFavorite` does (added/removed/
|
||||
updated by id, right inline via `FavoritesController.applyFavoriteToggle`).
|
||||
**Note**: this used to be a "favorites-only" filter toggle on the Files
|
||||
tab's controls row instead of its own tab, filtering the currently
|
||||
browsed folder's `_items`. That had two real bugs in sequence: first,
|
||||
the filter dropped every non-favorited item *including folders*, so a
|
||||
non-favorited folder (containing a favorited item nested inside)
|
||||
vanished from the listing entirely, with no way to navigate into it;
|
||||
fixing that by exempting folders from the filter was itself wrong,
|
||||
because the actual intent was for favorites-only to show every
|
||||
favorited item *account-wide*, not just direct children of whatever
|
||||
folder was open - a strict filter over the wrong scope. Converting it to
|
||||
a real tab, backed by an account-wide fetch, was the actual fix; keep
|
||||
favorites account-wide rather than reintroducing a current-folder-scoped
|
||||
filter.
|
||||
- Auth header is HTTP Basic (`username:appPassword`, base64), built in
|
||||
`_headers`/exposed as `authHeaders` for widgets that need to hit URLs
|
||||
directly (e.g. `Image.network(url, headers: service.authHeaders)` for
|
||||
thumbnails/previews).
|
||||
- Downloads stream through `Dio` (`downloadToFile`) for progress callbacks;
|
||||
small in-app previews (text/PDF) use `fetchBytes` via `package:http`.
|
||||
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath,
|
||||
{onProgress})`) stream the local file via `Dio().put()` with an explicit
|
||||
`Content-Length` and `onSendProgress`, mirroring the download path. The
|
||||
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
||||
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
||||
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
||||
- `NextcloudService.downloadToFile` (`Dio`, progress callbacks) is still
|
||||
used for small in-app-only downloads: text/PDF previews (`fetchBytes` via
|
||||
`package:http`) and "open externally" (`FileViewerScreen._downloadToTemp`
|
||||
streams to the app's own cache dir so `open_file` can hand it to another
|
||||
app). Explicitly saving a file **to the device** - the "Download" action
|
||||
in Files/Photos' selection toolbar and the media viewer's download button
|
||||
- instead hands off to `DownloadService.kt`, an Android foreground
|
||||
service, the same way "Share to Noo" hands its upload off to
|
||||
`ShareUploadService.kt` (see that section below) rather than downloading
|
||||
in Dart and prompting `file_saver` per file: a real Service survives the
|
||||
app being closed mid-download, with one cancellable notification for the
|
||||
whole batch. `DownloadService.kt` re-implements a plain WebDAV GET in
|
||||
Kotlin for the same reason `ShareUploadService.kt`'s PUT does - keep
|
||||
`NextcloudService.downloadToFile` in sync manually if download semantics
|
||||
change - and writes straight into the device's public Downloads
|
||||
collection via `MediaStore.Downloads` (API 29+; a background Service
|
||||
can't prompt `file_saver`'s SAF picker the way the Flutter/Activity side
|
||||
can, so this is the direct equivalent) with a legacy
|
||||
`Environment.DIRECTORY_DOWNLOADS` file-write fallback pre-Android-10.
|
||||
Folders are filtered out client-side before handing off (no recursive/
|
||||
zip download support); `DetailsVersionsTab`'s version-restore download
|
||||
and `ShareSheet`'s "Share file directly" (native OS share, not saved to
|
||||
Downloads) keep using `downloadToFile` directly instead, since both need
|
||||
the bytes in-app rather than saved to Downloads.
|
||||
- **Move/Copy** (`moveItem`/`copyItem(itemPath, destFolderPath, {overwrite,
|
||||
newName})`) share one private `_moveOrCopy` helper with `renameItem`
|
||||
(itself just a same-folder MOVE) - WebDAV `MOVE`/`COPY` are the same
|
||||
request shape, just a different verb, and both are recursive by default
|
||||
for a folder ("collection"), so no extra `Depth` header is needed. They
|
||||
return the raw HTTP status rather than a bool: `412 Precondition Failed`
|
||||
is WebDAV's standard signal for "something's already there" when
|
||||
`Overwrite: F`, which is exactly the conflict `ItemOperations.moveItems`/
|
||||
`copyItems` need to detect without a separate existence-check request
|
||||
per item. `ItemOperations` attempts every item in the batch first,
|
||||
collects conflicts into `MoveCopyResult.conflicts`
|
||||
(`models/move_copy_result.dart`), and only then shows one summary
|
||||
(`MoveCopyConflictSheet`) instead of prompting per conflict as they're
|
||||
hit; resolving picks overwrite/keep-both (auto-renamed via
|
||||
`_nextAvailableName` against a fresh listing of the destination, fetched
|
||||
once up front, not per item)/skip per item via `resolveConflicts`. The
|
||||
destination-picker screen behind this
|
||||
(`views/move_copy_destination_picker.dart`) is covered in
|
||||
`architecture.md`, including why it can't reuse the Files tab's shared
|
||||
navigation state the way `ShareUploadView` does.
|
||||
- **Uploads**: there's no more in-app-only upload path - `NextcloudService`'s
|
||||
own `uploadFileFromPath` was removed once the Files tab's "+" → "Upload
|
||||
File" was unified with the share-to-upload flow (below). Every upload,
|
||||
however it's triggered, now goes through `ShareUploadView` (caller-chosen
|
||||
destination via `FilesController.navigateToAbsoluteFolder`, then
|
||||
`UploadService`/`ShareUploadService.kt`).
|
||||
- **Receiving a shared file from another app**: hand-rolled in
|
||||
`MainActivity.kt` (Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`,
|
||||
`android:launchMode` `singleTask` in the manifest so a second share while
|
||||
@@ -94,7 +189,7 @@ rather than needing a rewrite for multi-account support.
|
||||
story. The fix: `getInitialShare`/`onNewShare` only ever query cheap Uri
|
||||
metadata (name/size/mime, not content) so `ShareUploadView`'s destination
|
||||
picker - which mirrors the Files tab's own controls/filters/listing,
|
||||
reusing the same `ServerProvider` fields and `widgets/item_icon.dart` -
|
||||
reusing the same `FilesController` fields and `widgets/item_icon.dart` -
|
||||
always appears instantly regardless of file size.
|
||||
- **Uploading a shared file**: once the user picks a destination in
|
||||
`ShareUploadView`, [`UploadService`](../../lib/services/upload_service.dart)
|
||||
@@ -111,18 +206,499 @@ rather than needing a rewrite for multi-account support.
|
||||
Dio from a separate process lifecycle - `UploadService.startUpload` passes
|
||||
everything the Kotlin side needs (the pre-built `Authorization` header
|
||||
from `NextcloudService.authHeaders`, not the raw password) as Intent
|
||||
extras, a one-way handoff with no channel back to Dart afterward. Keep
|
||||
the two upload implementations in sync manually if upload semantics
|
||||
change. Progress/cancellation is entirely notification-driven (one
|
||||
ongoing, updatable notification for the whole batch; its Cancel action
|
||||
re-delivers an Intent to the same running service instance, which an
|
||||
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to
|
||||
the Dart UI, by design, since the app may not even be running.
|
||||
extras, a one-way handoff for the start of the upload - the Kotlin side
|
||||
never asks Dart anything mid-upload, since the app may not even be
|
||||
running by then. Keep the two upload implementations in sync manually if
|
||||
upload semantics change. Progress/cancellation is entirely notification-
|
||||
driven (one ongoing, updatable notification for the whole batch; its
|
||||
Cancel action re-delivers an Intent to the same running service instance,
|
||||
which an `AtomicBoolean` the copy/upload loops poll) - the notification is
|
||||
the only UI a closed app gets.
|
||||
There is one thing that *does* come back, when the app is still running:
|
||||
once a batch finishes with at least one success, `ShareUploadService.kt`
|
||||
publishes into `UploadEventBus` (an in-process pub/sub, same shape as
|
||||
`SyncStatusBus` below), which `MainActivity.kt` forwards to Dart over the
|
||||
`dev.ayushya.noo/upload_service/status` `EventChannel` -
|
||||
`UploadService.completions`. `FilesController` subscribes in its own
|
||||
constructor and calls `refreshData()` when the event's folder matches
|
||||
`currentFolderPath`, so a file uploaded into the folder currently on
|
||||
screen shows up without a manual pull-to-refresh. Follows
|
||||
`SyncService.statusStream`'s "one shared `static final` stream" rule (see
|
||||
its own doc comment) - a second `receiveBroadcastStream()` subscriber
|
||||
would silently steal the single native-side listener from the first.
|
||||
|
||||
## Being picked by other apps (photo/file picker)
|
||||
|
||||
Noo can also be launched *by* another app as a `GET_CONTENT` picker (e.g.
|
||||
Google Drive/Instagram's "choose a file" flow), the reverse direction of
|
||||
"Share to Noo" above - hand-rolled the same way, not a plugin.
|
||||
|
||||
- `MainActivity.kt` matches `ACTION_GET_CONTENT` (`OPENABLE`, any
|
||||
mimeType - a single `*/*` filter, since Android matches it against
|
||||
whatever the caller actually requested) alongside its existing
|
||||
`ACTION_SEND`/`SEND_MULTIPLE` filters, and exposes the caller's requested
|
||||
mimeType/multi-select flag/app label via the
|
||||
`dev.ayushya.noo/pick_intent` method+event channel pair
|
||||
([`PickIntentService`](../../lib/services/pick_intent_service.dart)/
|
||||
[`PickRequest`](../../lib/models/pick_request.dart)) - same
|
||||
cold-start-vs-already-running split as the share-intent channels.
|
||||
- `PickController.pickRequest`/`isPicking` drive picking mode app-wide once
|
||||
`MainShellView` learns about a request at startup or via
|
||||
`onNewPickRequest`. While picking, `MainShellView` restricts the visible
|
||||
bottom-nav tabs to just Files and Photos (see `architecture.md`) -
|
||||
`FilesView`/`PhotosView` route taps through
|
||||
`itemMatchesPickFilter`/`confirmPick` instead of their normal
|
||||
open/select behavior (folders still navigate; a mime-mismatched file is
|
||||
rejected with a snackbar; matching files toggle-select or immediately
|
||||
confirm depending on `PickRequest.allowMultiple`).
|
||||
- `PickController.confirmPick` downloads the selected item(s) to a
|
||||
`picker/` scratch subfolder in the app's cache dir (`downloadToFile`,
|
||||
same as any other download) - each item into its own `picker/<item.id>/`
|
||||
subfolder, keeping the on-disk filename as plain `item.name` rather than
|
||||
prefixing it with the id to dodge collisions between same-named items;
|
||||
the caller reads that on-disk name back as the display name, so
|
||||
prefixing it there was a real bug (Drive showing e.g. `163332_photo.jpg`
|
||||
instead of `photo.jpg`) - then hands the local paths to
|
||||
`PickIntentService.finishPick`, which calls back into
|
||||
`MainActivity.kt.finishPick`: it wraps each file in a `content://` Uri
|
||||
via this app's own `FileProvider` (`${applicationId}.picker.fileprovider`,
|
||||
scoped to just that cache subfolder - see `android/app/src/main/res/xml/
|
||||
file_paths.xml`) and returns it to the caller via `setResult`. Single
|
||||
file uses `setDataAndType` (never `.data =`/`.type =` as two separate
|
||||
calls - each one silently nulls out the other field on a plain
|
||||
`Intent`); multiple files use `ClipData`. `cancelPick` mirrors this for
|
||||
backing out (system back while picking, or a picked-item mismatch) with
|
||||
`RESULT_CANCELED` instead.
|
||||
|
||||
## Device sync
|
||||
|
||||
Mirrors selected folders to app-private local storage
|
||||
(`getExternalFilesDir(null)/sync/<accountId>/...` - wiped on uninstall, no
|
||||
extra storage permission needed) and keeps them updated in the background,
|
||||
even with the app fully closed. Same rationale as the upload/download
|
||||
services for going native instead of a Dart background-task plugin (see
|
||||
above): a periodic job has to run without the Flutter engine loaded, and a
|
||||
notification action has to resolve without launching the UI. Rather than
|
||||
add `workmanager` (whose Dart `callbackDispatcher` spins up a second,
|
||||
minimal Flutter engine that has to re-register every plugin it touches),
|
||||
the whole engine is plain Kotlin using Android's WorkManager directly.
|
||||
|
||||
- [`SyncEngine.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncEngine.kt)
|
||||
holds the shared primitives, reused by both workers below: a Depth-1 (or
|
||||
Depth-0, for refreshing one item) PROPFIND (`propfindChildren`/
|
||||
`propfindSelf`) requesting `d:getetag` alongside the usual props - unlike
|
||||
every PROPFIND in `nextcloud_service.dart`, which never requests it
|
||||
(`etag` is a dead field on `NextcloudItem` today); plain
|
||||
`HttpURLConnection` GET/PUT (`downloadFile`/`uploadFile`, same style as
|
||||
`DownloadService.kt`/`ShareUploadService.kt`); and `diffFolder`, which
|
||||
compares one folder's freshly-walked manifest against the persisted
|
||||
per-account sync-state map (native `SharedPreferences`, JSON keyed by
|
||||
`oc:fileid` - stable across renames/moves, unlike `path`) to decide, per
|
||||
file: download (new, or server `etag` changed), upload (local file's
|
||||
mtime/size changed and the server didn't), delete locally (missing
|
||||
server-side, unchanged locally), re-download a file whose local copy is
|
||||
missing even though state says it was mirrored (stale state from an
|
||||
earlier sync of the path - treating that as "user deleted it" used to
|
||||
skip the download and report phantom removals), or flag a
|
||||
**conflict** (both changed since the last recorded state). The
|
||||
"removed" count in the summary notification only counts local files that
|
||||
actually existed and were deleted.
|
||||
- [`SyncWorker.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt)
|
||||
(`CoroutineWorker`) is both the periodic job and the one-off "Sync now":
|
||||
for each configured path, `propfindSelf`s it first to check whether it's
|
||||
a file or a folder - a folder gets the full recursive
|
||||
`walkRemoteTree`, a file is diffed directly as a one-item list (nothing
|
||||
else about `diffFolder`/download/upload/delete cares whether its entries
|
||||
came from a walk or a single lookup, so single-file sync needed no engine
|
||||
changes, just this one branch) - applies `diffFolder`'s decisions, then
|
||||
posts a summary notification (files updated/uploaded/removed) and, for
|
||||
any conflicts, one notification per file with two actions. A progress
|
||||
notification (`notifyProgress`) is shown lazily - only once the first
|
||||
actual download/upload starts, not unconditionally at the top of every
|
||||
run, so a periodic pass that finds nothing to transfer never flashes a
|
||||
notification at all. It shares the summary's notification ID
|
||||
(`SUMMARY_NOTIFICATION_ID`) on purpose: the final `notifySummary` call
|
||||
naturally replaces the ongoing progress notification in place once the
|
||||
run finishes (no flicker of two separate notifications), and if nothing
|
||||
ended up changing, `doWork` explicitly cancels that ID since there's no
|
||||
summary to replace it with.
|
||||
- **Live status reaches Dart via a push channel, not polling** -
|
||||
[`SyncStatusBus.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt)
|
||||
is a plain in-process pub/sub (no IPC needed - the workers and
|
||||
`MainActivity` share one process) that `SyncWorker`/`ConflictResolveWorker`
|
||||
publish into (syncing started/stopped, which `fileId`s are mid-transfer
|
||||
right now, new/resolved conflicts) and `MainActivity.kt`'s
|
||||
`dev.ayushya.noo/sync_service/status` `EventChannel` forwards to Dart,
|
||||
same pattern as the share/pick-intent channels. It deliberately does
|
||||
*not* track "which files are already synced" itself - that's read fresh
|
||||
from `SyncEngine`'s durable per-account state map
|
||||
(`SyncEngine.loadState(accountId).keys`) each time a snapshot is built,
|
||||
so there's one source of truth for "synced" instead of two that could
|
||||
drift - which is also why `SyncWorker.doWork()` saves state *before*
|
||||
its final `SyncStatusBus.setSyncing(accountId, false)` publish, not
|
||||
after: that publish is what tells Dart's live listener to recompute
|
||||
`syncedFileIds`, so publishing first would hand back a snapshot still
|
||||
missing every file the run just downloaded, with no further event ever
|
||||
arriving afterward to correct it - newly-synced files would sit with no
|
||||
badge until the app restarted and force-refreshed via `getStatus()`.
|
||||
`SyncService.getStatus(accountId)` (one-shot, seeds `SyncStatusController`
|
||||
right after login/account-switch - the account id is passed explicitly
|
||||
because `SyncStatusBus` only learns an account once a sync pass has run
|
||||
in this process, so on a fresh app start it'd report empty
|
||||
`syncedFileIds`; `SyncStatusController._applySnapshot` likewise ignores
|
||||
the bus's initial null-account emission so it can't wipe that seed) and `SyncService.statusStream` (live)
|
||||
both return the same snapshot shape. `SyncStatusController.syncHeaderStatus`
|
||||
(off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent
|
||||
chip/panel, replacing what used to be the WebDAV-refresh-loading
|
||||
indicator there) and `syncStatusFor(item)` (none/syncing/synced/conflict
|
||||
- drives the small corner badge on Files' tiles, `SyncStatusBadge`) are
|
||||
both computed from this state, not fetched per-item. Folders never get
|
||||
their own entry in the native state map (`diffFolder` only ever tracks
|
||||
individual files - `if (entry.isFolder) continue`), so `syncStatusFor`
|
||||
derives a folder's badge differently than a file's: `synced` once the
|
||||
folder's own path (or an ancestor of it) is in sync scope
|
||||
(`_isPathInSyncScope`, shared with `localSyncedFilePath`), `syncing`
|
||||
while any sync pass is running, `conflict` if a pending conflict's
|
||||
`remotePath` falls under it - not from `syncedFileIds`, which only ever
|
||||
contains individual files.
|
||||
- **In-app conflict resolution reuses the exact same enqueue path as the
|
||||
notification actions** - `ConflictResolveWorker.enqueue(...)` is a
|
||||
shared companion function; `SyncConflictReceiver` (the notification
|
||||
action) and `MainActivity.kt`'s `resolveConflict` MethodChannel method
|
||||
(the sync header's "Keep local"/"Use server" buttons,
|
||||
`SyncStatusController.resolveSyncConflict`) both just call it, so there's
|
||||
one resolution code path regardless of which surface triggered it.
|
||||
- **Conflicts are never auto-resolved.** The notification's "Keep local"/
|
||||
"Use server" actions are `PendingIntent.getBroadcast`s (same shape as the
|
||||
Cancel action on upload/download notifications, just broadcast instead of
|
||||
service-targeted) to
|
||||
[`SyncConflictReceiver`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncConflictReceiver.kt)
|
||||
- a manifest-registered `BroadcastReceiver` (works even with the app
|
||||
process dead) that can't itself block on network, so it just dismisses
|
||||
the notification and enqueues a one-shot
|
||||
[`ConflictResolveWorker`](../../android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt)
|
||||
to actually push the local copy up or pull the server copy down and
|
||||
refresh that file's recorded state.
|
||||
- **Keeping synced files current automatically - no "Sync now" needed.**
|
||||
Synced paths follow the Files Cache rule (Settings, right under Device
|
||||
Sync; default *Refresh periodically, 15 min*), driven by
|
||||
`SyncStatusController` (see its doc comment): *periodically* = a
|
||||
WorkManager `PeriodicWorkRequest` at `max(interval, 15)` min (Android's
|
||||
floor) via `reschedule(intervalMinutes:)`, plus an in-app `Timer` at the
|
||||
exact interval and a pass on app resume if one is due; *never cache* = no
|
||||
background job, a pass on each app open/resume; *manual* = only
|
||||
pull-to-refresh (`SyncedHeaderScaffold` calls `syncOnPull`, on any tab) and
|
||||
"Sync now". **There's no server push:** Nextcloud's push options
|
||||
(`notify_push` needs a server app plus a persistent WebSocket, which
|
||||
Android kills in the background without a foreground service; Nextcloud's
|
||||
FCM/UnifiedPush proxy needs a registered app identity) aren't viable for
|
||||
a self-hosted-server client, so it polls - and polling is made cheap by the
|
||||
**root-etag shortcut**: Nextcloud propagates any descendant change up
|
||||
through every ancestor folder's `getetag`, so each run first does one
|
||||
Depth-0 PROPFIND per synced root and skips the recursive walk if the etag
|
||||
matches the stored `SyncEngine.RootMarker` *and* every local file still
|
||||
matches its recorded size/mtime (`localMatchesState` - nothing to upload or
|
||||
re-download). A root is only marked after a fully clean pass (no failed
|
||||
transfer, no conflict), a full walk is forced at least every 6 h
|
||||
(`FULL_WALK_MAX_AGE_MS`; etag propagation is unreliable on external
|
||||
storage), and "Sync now" (`KEY_FORCE`) always walks. Automatic/pull passes
|
||||
are `force: false`: silent (progress notification only once a transfer
|
||||
starts), `ExistingWorkPolicy.KEEP` (never cancel a run in progress), and
|
||||
automatic ones also carry the Wi-Fi-only constraint. **Safety:** PROPFIND
|
||||
failures (network error, 5xx) throw `RemoteUnavailableException` and the
|
||||
path is skipped that run - previously they returned an empty listing,
|
||||
which made every synced file look deleted server-side and the diff deleted
|
||||
the local copies (a real risk once passes run every few minutes). Only a
|
||||
clean 404 means "gone".
|
||||
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
|
||||
(`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge
|
||||
from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are
|
||||
fixed at enqueue time, so changing the synced-folder list, the active
|
||||
account, or the Wi-Fi-only setting means cancelling and re-enqueueing,
|
||||
not updating in place. [`SyncService`](../../lib/services/sync_service.dart)
|
||||
(Dart) wraps this - `SyncStatusController` calls `SyncService.reschedule`
|
||||
after every successful login/account switch and every synced-folder/
|
||||
cache-rule/`syncOnCellular` change. `reschedule` covers **every saved
|
||||
account**, not just the active one: each account with sync enabled gets its
|
||||
own periodic job (`SyncWorker.periodicNameFor(accountId)`) carrying that
|
||||
account's own credentials (read from `AccountStore`), paths and interval -
|
||||
the active account's from live `SyncStatusController` state, the others'
|
||||
from their persisted per-account prefs (`_SyncConfig.fromPrefs`). Logging
|
||||
out stops that account's background sync: `SessionController.logout`
|
||||
records it in `AccountStore`'s signed-out set (so a later `reschedule` for
|
||||
another account doesn't resurrect its job) and cancels it via
|
||||
`SyncService.cancelAccount`; activating the account again clears the flag.
|
||||
Removing an account cancels its job too. One-off runs are per-account
|
||||
(`oneOffNameFor`).
|
||||
- **Folders, not just files, are mirrored.** After applying a path's diff,
|
||||
`SyncWorker` calls `SyncEngine.mirrorFolders` (a local directory for every
|
||||
remote folder, so an empty folder created on the web appears on device) and
|
||||
`pruneRemovedFolders` (removes local directories the server no longer has,
|
||||
but only *empty* ones - the diff has already deleted the files that were in
|
||||
them - and never the sync root). Root-etag markers carry a version
|
||||
(`ROOTS_VERSION`) so bumping it forces one full walk after a change like this.
|
||||
A configured path the server 404s (deleted on the web) is recorded via
|
||||
`setRootMissing`; the snapshot's `missingRoots` lets
|
||||
`SyncStatusController._applySnapshot` drop it from the synced list.
|
||||
- **Notifications.** Sync notifications are silent (channel `device_sync_v2`,
|
||||
`IMPORTANCE_LOW` + `setSilent`), with ids and titles scoped per account
|
||||
(`summaryNotificationId`/`conflictNotificationId`) so accounts don't
|
||||
overwrite each other. "Background sync notifications" (Settings, global,
|
||||
default on) gates progress/summary for automatic runs (`KEY_NOTIFY`,
|
||||
baked into the periodic job input so it needs a reschedule); conflicts and
|
||||
user-initiated "Sync now" always notify. Manual download/upload
|
||||
notifications use `file_downloads_v2`/`share_upload_v2` at default importance
|
||||
(progress silent, completion audible). Channel importance can't be changed
|
||||
once created, so changing it means a new channel id plus
|
||||
`NooNotificationChannels.ensure(legacyIds = ...)` deleting the old one.
|
||||
- **One sync at a time.** `SyncEngine.syncLock` (a process-wide coroutine
|
||||
`Mutex`) wraps both `SyncWorker` and `ConflictResolveWorker`. WorkManager
|
||||
runs differently-named jobs concurrently, and both workers load the whole
|
||||
sync-state map, mutate it and save it back - so overlapping runs (periodic
|
||||
+ a pull, two accounts due at once) silently discarded each other's
|
||||
updates, and `SyncStatusBus` only tracks one account at a time. A run that
|
||||
has to wait simply starts when the current one finishes. The network constraint is `NetworkType.UNMETERED` by default
|
||||
(`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's
|
||||
opted into cellular sync. Turning sync off for a path
|
||||
(`SyncStatusController.removeSyncedPath`) also calls
|
||||
`SyncService.removeLocalSync`, which runs `SyncEngine.removeLocalSync` on
|
||||
a background thread (deletes the local mirror files under that path
|
||||
*and* their entries in the native sync-state map - clearing state too,
|
||||
not just the files, matters because a bare "file's gone but the server
|
||||
hasn't changed" without a state reset reads as a user-initiated local
|
||||
deletion to `diffFolder`, so a later re-add wouldn't re-download
|
||||
anything).
|
||||
- Synced-path list (`SyncStatusController.syncedPaths` - files or folders, not
|
||||
just folders despite the name of the underlying pref/native `Data` key,
|
||||
which stayed `ui_synced_folders`/`folders` to avoid a storage-key
|
||||
migration for a rename) follows the standard per-account-pref pattern
|
||||
(JSON-encoded string list, in `AccountStore.perAccountPrefKeys`); a
|
||||
parallel `_syncedPathTypes` map (path -> isFolder, `ui_synced_folder_types`)
|
||||
tracks which of those paths are folders vs individual files for the sync
|
||||
header's folder/item counts, defaulting missing entries to folder (the
|
||||
common case, and what any path added before this map existed will look
|
||||
like). **`addSyncedPaths`/`removeSyncedPaths` gate on
|
||||
`_accountLoadedGate`** (a `Completer`, deliberately starting
|
||||
*incomplete* - not pre-completed - completed once
|
||||
`_onAccountActivated`'s async per-account prefs load actually finishes,
|
||||
re-armed on `_onAccountCleared`) before touching `_syncedPaths` at all -
|
||||
without this, syncing a folder soon enough after opening the app (or an
|
||||
account switch) could race that load: the mutator spreads the *current
|
||||
in-memory* `_syncedPaths` (still `[]`, the pre-load default) and
|
||||
immediately persists the result, silently overwriting the
|
||||
previously-saved list and losing every other folder that had been
|
||||
synced before. Starting the gate pre-completed was an actual bug here -
|
||||
it meant only account *switches* (which call `_onAccountCleared`,
|
||||
re-arming it) were protected, leaving the very first cold-start load
|
||||
completely exposed to the race. A second, related guard,
|
||||
`_hasLoadedSyncedPathsForAccount`, stops `_onAccountActivated` from
|
||||
re-reading `_syncedPaths` from storage more than once per account -
|
||||
activation can fire again for the same account (e.g.
|
||||
`SessionController` re-verifying a provisional/offline login once
|
||||
connectivity returns), and a second read could clobber an in-memory
|
||||
mutation made between the first load and that one if its own persist
|
||||
hadn't landed yet. `syncEverything`
|
||||
(`ui_sync_everything`, per account) works the
|
||||
same way - when on, `SyncService` sends `['/']` as the path list instead
|
||||
of `syncedPaths`, mirroring the whole account rather than requiring
|
||||
per-item opt-in. `syncOnCellular` is a plain global pref. `syncEverything`
|
||||
and `syncOnCellular` are managed from Settings → Device Sync (a "Sync
|
||||
everything" switch, the cellular toggle, a manual "Sync now", and a
|
||||
"View offline files" row that pushes the Offline tab - see below); the
|
||||
configured path list itself, with its remove buttons, lives on the
|
||||
Offline tab now, not Settings. Individual files or folders are toggled
|
||||
from Files' selection toolbar ("Sync to device", works over the whole
|
||||
selection at once - either item type, folders or files - not just a
|
||||
single item; the action reads as "stop syncing" only once every selected
|
||||
item is already synced, otherwise it syncs whichever ones aren't yet,
|
||||
and either direction ends with a confirmation SnackBar). Multi-item
|
||||
add/remove goes through `SyncStatusController.addSyncedPaths`/
|
||||
`removeSyncedPaths` (batched), never a per-item loop of
|
||||
`addSyncedPath`/`removeSyncedPath` - looping was an actual bug: each
|
||||
`addSyncedPath` call fires its own `SyncService.syncNow`, and
|
||||
`syncNow`'s native side enqueues via `WorkManager.enqueueUniqueWork(...,
|
||||
ExistingWorkPolicy.REPLACE, ...)`, so a second item's call cancelled the
|
||||
first item's still-in-flight sync pass instead of letting it finish -
|
||||
only ever syncing the last item enqueued. The batched methods mutate
|
||||
`_syncedPaths` for the whole set and call `reschedule`/`syncNow` exactly
|
||||
once, with the complete folder list, so `SyncWorker` handles every item
|
||||
in one run (it already loops its whole `folders` list sequentially
|
||||
within a single `doWork()` call - see above).
|
||||
- **The Offline tab** (`FilesView(offline: true)` over
|
||||
[`OfflineController`](../../lib/providers/offline_controller.dart), see
|
||||
`architecture.md`'s `FolderBrowser`) is the Files tab itself - same
|
||||
breadcrumbs, controls, tiles and thumbnails - over whatever device-sync
|
||||
has actually landed on disk - deliberately no selection/multi-select
|
||||
toolbar, since delete/share/move don't make sense for an already-synced
|
||||
local mirror.
|
||||
Reads straight off `<externalFilesDir>/sync/<accountId>/<currentFolder>`
|
||||
(non-recursive `Directory.list()` per folder) via `dart:io`, not fetched
|
||||
from the server (mirrors `SyncEngine.kt#syncRoot`'s layout exactly), so
|
||||
it works with no connection and never round-trips through a
|
||||
MethodChannel just to list files. `OfflineController` refetches the
|
||||
current folder automatically whenever a `SyncService.statusStream`
|
||||
snapshot shows syncing just stopped, so newly-downloaded files show up
|
||||
without a manual pull-to-refresh. Tapping an item opens
|
||||
`FileViewerScreen` with `localPath` set (see below) - same in-app viewer
|
||||
Files uses, just reading from disk instead of the server; tapping a
|
||||
folder navigates into it, same as Files. Resolves the local path via
|
||||
`OfflineController.localPathFor(item)`, a pure function of the item's
|
||||
path - deliberately *not*
|
||||
`SyncStatusController.localSyncedFilePath`, which additionally
|
||||
re-verifies the item falls under a configured sync target
|
||||
(`_isPathInSyncScope`). That check is redundant and was actually a bug
|
||||
here: every item this controller ever hands out already came from
|
||||
listing this exact directory tree, so it's definitionally already
|
||||
local, and re-deriving "is this still in scope" from `syncedPaths`
|
||||
could disagree with what's genuinely sitting on disk (e.g. nested
|
||||
paths, timing right after a scope change) and report a visibly-listed
|
||||
file as "no longer available". Pull-to-refresh triggers
|
||||
`SyncService.syncNow` followed by a re-list. The
|
||||
configured sync targets themselves (with "stop syncing" per target, what
|
||||
used to be Settings' Device Sync card's own inline list) live in a
|
||||
bottom sheet behind the app bar's sync icon (`_ManageSyncedFoldersSheet`)
|
||||
rather than inline in the main view, so the browser itself stays a plain
|
||||
Files-style listing; Settings' own "View offline files" row just pushes
|
||||
this whole tab.
|
||||
- **`android/app/proguard-rules.pro` exists specifically for this feature,
|
||||
and keeps `androidx.work.**` wholesale rather than naming individual
|
||||
classes.** Flutter's own Gradle plugin auto-enables R8 minification for
|
||||
release builds (`FlutterPlugin.kt` sets `isMinifyEnabled = true`
|
||||
unconditionally for the `release` build type, and auto-wires this exact
|
||||
file if it exists - nothing in this project's own `build.gradle.kts`
|
||||
opts into it), which broke device sync on real-device testing **twice**
|
||||
in a row: first `WorkDatabase` (WorkManager locates its bundled Room
|
||||
database by reflecting off the abstract database class's own,
|
||||
possibly-renamed, name), then - after narrowly keeping just that class -
|
||||
`OverwritingInputMerger` (WorkManager's default input merger, also
|
||||
reflection-instantiated) broke the exact same way and silently ate every
|
||||
`enqueueUniqueWork` call, including "Sync now", with zero indication
|
||||
beyond a `WM-InputMerger` `NoSuchMethodException` in logcat - no crash,
|
||||
no Dart-visible error, just a folder that stayed empty. R8's member-level
|
||||
shrinking strips whatever a class's *reflection-only* callers don't
|
||||
reference directly, even when the class itself survives a plain `-keep
|
||||
class` with no wildcard, and WorkManager reflects into more of its own
|
||||
internals than any one test pass is likely to exercise - hence the
|
||||
wholesale keep instead of chasing individual classes one crash at a
|
||||
time. If adding another native background component reached only via
|
||||
reflection (not a manifest-declared component, which AGP already keeps
|
||||
automatically), don't assume default AndroidX consumer rules cover it -
|
||||
verify on an actual release build, not just `flutter analyze`/a debug
|
||||
build, since minification only applies to release.
|
||||
- **Files land under `Android/data/<package>/files/sync/...`
|
||||
(`getExternalFilesDir`), which no third-party file manager can browse
|
||||
without root** - Android's scoped storage sandboxes that whole directory
|
||||
tree from other apps by design, same as any app-private storage. This
|
||||
surprised real-device testing (a file manager app logged "Can't read
|
||||
directory ... trying su" and came up empty even though the sync had
|
||||
actually worked) - it's expected, not a bug. Confirm synced files
|
||||
landed via `adb shell run-as`/a rooted shell, not a regular file
|
||||
manager UI.
|
||||
- **Already-synced files skip the network** in two places:
|
||||
`SyncStatusController.localSyncedFilePath(item)` is a pure function of the
|
||||
remote path (mirrors `SyncEngine.kt`'s `syncRoot` layout exactly, so Dart
|
||||
never needs to read the native sync-state `SharedPreferences`) that
|
||||
returns the local mirror path if it exists on disk. `ShareSheet`'s "Share
|
||||
file directly" and `FilesView._downloadSelected` (only when *every*
|
||||
selected file is already synced - a mixed selection still goes through
|
||||
the normal `DownloadService` batch) both check it first.
|
||||
|
||||
## Working offline
|
||||
|
||||
[`ConnectivityController`](../../lib/providers/connectivity_controller.dart)
|
||||
wraps `connectivity_plus` (OS-level route detection - Wi-Fi/mobile/none,
|
||||
not a guarantee the Nextcloud server itself is reachable) and is the
|
||||
single source of truth every offline-aware decision in the app consults.
|
||||
**Its very first `checkConnectivity()` call is re-verified once more,
|
||||
~2 seconds later** - that first check can spuriously report "no network"
|
||||
while Android's connectivity stack is still attaching callbacks to a
|
||||
just-started process (a real cold-start quirk, not a genuine transition),
|
||||
and since `onConnectivityChanged` only fires on actual transitions, a
|
||||
false initial "offline" read would otherwise stick for the rest of the
|
||||
session with no further event ever correcting it - `SessionController`
|
||||
would keep treating the login as provisional indefinitely, and every
|
||||
network-fetching controller (Files/Photos/Favorites/Trash/Shares/Recent)
|
||||
would simply never receive its real activation signal, leaving every tab
|
||||
permanently empty despite the device being online the whole time. This
|
||||
was a real, previously-shipped regression, not a hypothetical.
|
||||
|
||||
- **Session restore never makes a doomed HTTP request.**
|
||||
`SessionController._applyCredentialsForAccount` checks
|
||||
`connectivity.isOffline` *before* calling `NextcloudService.testConnection()`
|
||||
- if there's no route at all, it skips the request entirely rather than
|
||||
letting it fail (fast or slow) and parsing the exception. Either way (no
|
||||
route, or a network-level exception once a request is attempted -
|
||||
timeout, DNS, unreachable host, a transient 5xx), the session logs in
|
||||
*provisionally*: `_isLoggedIn = true` with the already-constructed
|
||||
`_service` kept around, `_isProvisionalLogin = true`. Only an actual 401
|
||||
(`_errorMessage` contains "401") is treated as a real rejection - drops
|
||||
the stored password and logs out for real. Provisional login exists
|
||||
specifically so a genuinely offline cold start still lands on
|
||||
`MainShellView` (restricted to the Offline tab - see below) instead of
|
||||
bouncing to `LoginView`, which would strand the user with no way back in
|
||||
short of Login Flow v2 again (`switchAccount` no-ops when "switching" to
|
||||
the account that's already nominally active, and `activeAccountId` is
|
||||
never cleared by a network failure).
|
||||
- **Two account-activation signals, not one**, precisely so a provisional
|
||||
login doesn't cascade into a pile of doomed requests from every other
|
||||
controller: `addAccountActivatedListener` (fires *only* on a real,
|
||||
verified login - what `FilesController`/`PhotosController`/
|
||||
`FavoritesController`/`TrashController`/`SharesController`/
|
||||
`RecentController` all register for, since their own activation work is
|
||||
a network fetch) vs. `addAccountReadyListener` (fires on *either* a
|
||||
verified or a provisional login - what `SyncStatusController`/
|
||||
`OfflineController` register for instead, since their own activation
|
||||
work - loading prefs, listing local files, calling the native sync
|
||||
MethodChannel - is local/native-only and safe with no connection at
|
||||
all). A provisional login fires only `ready`, never `activated`; a
|
||||
verified login fires both.
|
||||
- **Reconnecting re-verifies automatically.** `SessionController` listens
|
||||
to `connectivity` itself; the moment it flips from offline to online
|
||||
while `_isProvisionalLogin` is still true, it re-runs
|
||||
`_applyCredentialsForAccount` with the same cached account/password. On
|
||||
success this is what finally fires `_notifyAccountActivated` for real,
|
||||
so Files/Photos/etc. get their first actual fetch without the user
|
||||
having to force-quit/restart the app.
|
||||
- **`MainShellView` collapses the bottom nav to just the Offline tab**
|
||||
while `connectivity.isOffline`, the same override mechanism already used
|
||||
for picking mode (see `architecture.md`) - every other tab would just
|
||||
show its own loading spinner or error state with no connection, so
|
||||
there's nothing useful to switch to. `MoreTabsButton` hides entirely in
|
||||
this state too, for the same reason it hides while picking - there's no
|
||||
hidden tab it could usefully open either.
|
||||
- **The sync header's "Offline" label wins over everything else.**
|
||||
`_syncHeaderDisplay`/`_syncSummary` (`synced_header_scaffold.dart`) both
|
||||
take a `bool isOffline` and check it first, ahead of conflicts/syncing/
|
||||
configured-targets - with no connection, *why* nothing's syncing right
|
||||
now matters more than what would otherwise be shown, so "Sync off"
|
||||
(nothing configured) and "Offline" (nothing *can* sync right now,
|
||||
regardless of configuration) stay distinct messages.
|
||||
- **`FileViewerScreen` can read a file straight from disk.** Its optional
|
||||
`localPathResolver` param (`Future<String?> Function(NextcloudItem)`,
|
||||
only ever set by the Offline tab, passing `OfflineController.
|
||||
localPathFor`) swaps every preview widget's data source -
|
||||
`_ImagePreview`/`_VideoPreview` use `Image.file`/
|
||||
`VideoPlayerController.file` instead of the `.network`/`.networkUrl`
|
||||
variants, `_PdfPreview`/`_TextPreview` read via
|
||||
`File(path).readAsBytes()` instead of `NextcloudService.fetchBytes` -
|
||||
same in-app viewer either way, no separate "offline preview" screen.
|
||||
Unlike a single up-front path, a *resolver* is what makes `siblings`
|
||||
(swipe-between-media) behave identically to Files/Photos while offline:
|
||||
the swipeable `PageView.builder` calls it again for whichever sibling
|
||||
you've swiped to (wrapped in a `FutureBuilder`, since each resolution
|
||||
is an async disk check), not just the item the viewer opened on - the
|
||||
Offline tab passes its whole current folder's `items` as `siblings`,
|
||||
same as Files does with its own. The action bar hides Favorite/Delete/
|
||||
Download-to-device (`showServerActions: false`) since those need a live
|
||||
server - Share and Open-externally still work (`_openExternally` calls
|
||||
the resolver directly instead of downloading to a temp file first;
|
||||
Share already prefers a local copy when one exists, see
|
||||
`ShareSheet._shareFileDirectly`).
|
||||
|
||||
## Multi-account storage & session persistence
|
||||
|
||||
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
||||
account-identity-related; `ServerProvider` owns everything about which
|
||||
account-identity-related; `SessionController` owns everything about which
|
||||
account is *currently* live (see `architecture.md`).
|
||||
|
||||
- **Per-account secrets**: one `flutter_secure_storage` key per account,
|
||||
@@ -141,26 +717,36 @@ account is *currently* live (see `architecture.md`).
|
||||
storage scope, show-hidden ×2, Photos sort field/ascending, Files'
|
||||
per-folder sort map, cache policy/interval — the full list is
|
||||
`AccountStore.perAccountPrefKeys`) are namespaced `acct_<accountId>_<key>`
|
||||
and reloaded on every switch via `ServerProvider._applyAccountPrefs`.
|
||||
and owned by whichever controller that domain belongs to (e.g.
|
||||
`FilesController` for grid/list/storage-scope/sort,
|
||||
`PhotosController` for Photos' own sort/filter). Each reloads its own
|
||||
slice in its `_onAccountActivated` listener, registered via
|
||||
`SessionController.addAccountActivatedListener` in its constructor -
|
||||
there's no longer one central method that reloads every domain's prefs at
|
||||
once; `SessionController` just fires the notification, and every sibling
|
||||
controller reacts independently.
|
||||
- **Legacy migration**: `AccountStore.migrateLegacyIfNeeded` runs once ever
|
||||
(guarded by the `account_migration_v1_done` flag), turning a pre-multi-
|
||||
account install's 3 flat secure-storage keys + flat browsing prefs into
|
||||
the first saved (and active) account, so upgrading users are never logged
|
||||
out. Never assume the legacy keys are gone — always check the migration
|
||||
flag rather than the keys' absence.
|
||||
- On startup, `ServerProvider._init()` awaits the migration, loads the
|
||||
account list + active id, then `_restoreSession()` looks up the active
|
||||
account's password and calls `_applyCredentialsForAccount` (the renamed,
|
||||
generation-guarded, account-aware version of what used to be
|
||||
`_applyCredentials`) to rebuild the session without re-hitting the login
|
||||
flow.
|
||||
- On startup, `SessionController`'s constructor awaits the migration, loads
|
||||
the account list + active id, then `_restoreSession()` looks up the
|
||||
active account's password and calls `_applyCredentialsForAccount`
|
||||
(generation-guarded, account-aware) to rebuild the session without
|
||||
re-hitting the login flow - firing `_notifyAccountActivated()` on
|
||||
success, which is what triggers every sibling controller's own initial
|
||||
fetch.
|
||||
- **Switching accounts** (`switchAccount`/`cycleToNextAccount`/
|
||||
`cycleToPreviousAccount`/`removeAccount`'s fallback, plus landing on a
|
||||
freshly-added account) all funnel through the single `_activateAccount`
|
||||
engine: bump `_sessionGeneration`, cancel any pending login flow, clear
|
||||
every content field *without* ever setting `isLoggedIn` false (that's the
|
||||
detail that keeps `main.dart`'s root routing from bouncing through
|
||||
`LoginView` mid-switch), reload the target account's prefs, then verify
|
||||
engine: bump `sessionGeneration`, cancel any pending login flow, call
|
||||
`_notifyAccountCleared()` (every sibling controller's registered
|
||||
`addAccountClearedListener` callback resets that controller's own state)
|
||||
*without* ever setting `isLoggedIn` false (that's the detail that keeps
|
||||
`main.dart`'s root routing from bouncing through `LoginView` mid-switch),
|
||||
then verify
|
||||
its credentials and refetch everything. This is a full teardown-and-reload
|
||||
every time — there is deliberately no simultaneous multi-account state or
|
||||
background sync; only one account's content is ever live.
|
||||
@@ -182,7 +768,7 @@ account is *currently* live (see `architecture.md`).
|
||||
that's actually destructive/irreversible - UI call sites (`AccountView`)
|
||||
gate it behind a confirmation dialog; `logout()` doesn't need one.
|
||||
- Any UI code that calls either and might have ended the session should
|
||||
check `!provider.isLoggedIn` afterward and `Navigator.popUntil((r) =>
|
||||
check `!session.isLoggedIn` afterward and `Navigator.popUntil((r) =>
|
||||
r.isFirst)` if so — otherwise a screen pushed on top (Settings) is left
|
||||
stranded over a root route that's silently swapped to `LoginView`
|
||||
underneath it. Don't pop unconditionally — removing a *non-active*
|
||||
@@ -198,7 +784,7 @@ app. [`AppLockService`](../../lib/services/app_lock_service.dart) wraps
|
||||
`authenticate()` always delegates to whatever the OS already has configured
|
||||
(biometric, or device PIN/pattern/password as fallback, via
|
||||
`biometricOnly: false`). Never build a custom in-app PIN screen for this —
|
||||
extend `AppLockService`/the `ServerProvider` gates described in
|
||||
extend `AppLockService`/the `SessionController` gates described in
|
||||
`architecture.md` instead.
|
||||
|
||||
**Android native requirements** (both already done, keep them if you touch
|
||||
@@ -215,3 +801,13 @@ Flutter's own default to already be high enough.
|
||||
`isRestoringSession` still gates the splash screen until the above resolves
|
||||
— see `standards.md` for why widget tests must mock both storage channels
|
||||
rather than relying on this async path throwing naturally.
|
||||
|
||||
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
|
||||
|
||||
## Changing a share's permissions
|
||||
|
||||
`NextcloudService.updateSharePermissions` does `PUT
|
||||
/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` with a `permissions`
|
||||
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||
|
||||
@@ -29,41 +29,65 @@ class/method already makes obvious.
|
||||
- Private helpers/widgets are prefixed with `_` and live in the same file as
|
||||
their one caller; promote to `widgets/` only once something is reused
|
||||
across files.
|
||||
- Read provider state with `context.watch<ServerProvider>()` in `build`,
|
||||
and `context.read<ServerProvider>()` for one-off calls from callbacks
|
||||
(matches `LoginView._handleContinue`).
|
||||
- Read controller state with `context.watch<XController>()` in `build`, and
|
||||
`context.read<XController>()` for one-off calls from callbacks (matches
|
||||
`LoginView._handleContinue`). Pull in only the specific controller(s) a
|
||||
widget actually needs (e.g. `FilesController` + `SyncStatusController`
|
||||
for a Files tile), not a catch-all — see `architecture.md`'s "State
|
||||
management" section for the full controller split and what each one owns.
|
||||
|
||||
## `ServerProvider` conventions
|
||||
## Controller conventions
|
||||
|
||||
- Any method that fetches data and writes it into a shared field
|
||||
(`refreshData`, `fetchAllMedia`, `fetchTrash`, `fetchShares`,
|
||||
`fetchRecent`, `_applyCredentialsForAccount`) must guard against a stale
|
||||
write from an account the user has since switched away from: capture
|
||||
`final gen = _sessionGeneration;` at entry, and check
|
||||
`if (gen != _sessionGeneration) return;` immediately after each `await`
|
||||
before touching any field or calling `notifyListeners()`. Follow this
|
||||
pattern for any new fetch method added to the provider.
|
||||
- New persisted state on `ServerProvider` must be classified global vs.
|
||||
per-account (see `architecture.md`/`server.md`) up front — global state
|
||||
uses a plain `_prefsFuture.then((p) => p.setX(key, value))`; per-account
|
||||
state goes through `_persistAccountPref(key, (p, namespacedKey) =>
|
||||
p.setX(namespacedKey, value))` and must also be handled in
|
||||
`_applyAccountPrefs` (both the "reset to default when no account" and the
|
||||
"load for this account" branches) so it's correct immediately after a
|
||||
switch, not just at startup.
|
||||
(`FilesController.refreshData`, `PhotosController.fetchAllMedia`,
|
||||
`TrashController.fetchAll`, `SharesController.fetchAll`,
|
||||
`RecentController.fetchAll`, `SessionController._applyCredentialsForAccount`)
|
||||
must guard against a stale write from an account the user has since
|
||||
switched away from: capture `final gen = session.sessionGeneration;` at
|
||||
entry, and check `if (gen != session.sessionGeneration) return;`
|
||||
immediately after each `await` before touching any field or calling
|
||||
`notifyListeners()`. Follow this pattern for any new fetch method added
|
||||
to any controller.
|
||||
- New persisted state must be classified global vs. per-account (see
|
||||
`architecture.md`/`server.md`) up front and live on whichever controller
|
||||
owns that domain — global state (`SettingsController`) uses a plain
|
||||
`_prefsFuture.then((p) => p.setX(key, value))`; per-account state goes
|
||||
through `_persistAccountPref(key, (p, namespacedKey) =>
|
||||
p.setX(namespacedKey, value))` and must also be handled in that
|
||||
controller's own `_onAccountCleared`/`_onAccountActivated` listeners
|
||||
(registered via `SessionController.addAccountClearedListener`/
|
||||
`addAccountActivatedListener` in the controller's constructor) so it's
|
||||
correct immediately after a switch, not just at startup.
|
||||
|
||||
## Testing
|
||||
|
||||
- Widget tests must mock platform channels that the app touches on startup
|
||||
— `SharedPreferences.setMockInitialValues({})` and a mock
|
||||
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler —
|
||||
and disable Google Fonts network fetching
|
||||
— `SharedPreferences.setMockInitialValues({})`, a mock
|
||||
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler,
|
||||
and `connectivity_plus`'s `MethodChannel('dev.fluttercommunity.plus/
|
||||
connectivity')` (`'check'` → a result list, e.g. `['wifi']`) plus its
|
||||
`EventChannel('dev.fluttercommunity.plus/connectivity_status')` (a
|
||||
`MockStreamHandler.inline` with a no-op `onListen` is enough) — and
|
||||
disable Google Fonts network fetching
|
||||
(`GoogleFonts.config.allowRuntimeFetching = false`) in `setUpAll`. Without
|
||||
these, `ServerProvider`'s session restore never resolves in the test
|
||||
these, `SessionController`'s session restore never resolves in the test
|
||||
sandbox (no plugin implementation is registered, so the read future just
|
||||
never completes) and the app stays on `_SplashView`'s indeterminate
|
||||
spinner, which makes `pumpAndSettle()` hang until its own timeout instead
|
||||
of failing fast. See `test/widget_test.dart` for the reference setup.
|
||||
- Design-system components (`lib/widgets/noo/`) are tested in
|
||||
`test/widgets/noo/`, one file per component folder. Use the helpers in
|
||||
`noo_test_utils.dart`:
|
||||
- `setUpNooTests()` turns off font fetching.
|
||||
- `testNooWidgets(...)` runs a body once per theme, light and dark, built
|
||||
through the real `AppTheme`, and hands it the `NooColors`.
|
||||
- `pumpNoo(...)` mounts the widget.
|
||||
|
||||
Build themes inside the test body, not at file level, because `AppTheme`
|
||||
touches Google Fonts before the test binding exists. `pumpNoo` centers
|
||||
the child, which loosens its constraints. To catch a widget stretching to
|
||||
fill its parent, put it in a bounded box (for example, `SizedBox` +
|
||||
`Align`).
|
||||
- Run with `flutter test`.
|
||||
|
||||
## Local install/deploy
|
||||
@@ -84,13 +108,22 @@ adb install -r build/app/outputs/flutter-apk/app-release.apk
|
||||
```
|
||||
|
||||
This only preserves data if the new APK's signature matches what's already
|
||||
on the device — a local build's release variant currently reuses the debug
|
||||
signing config (`android/app/build.gradle.kts`), so consecutive local
|
||||
builds share a key and `adb install -r` works cleanly. Installing a build
|
||||
signed with a different key (e.g. a CI-signed release APK from the Gitea
|
||||
release pipeline) over a differently-signed local build forces Android to
|
||||
require a full uninstall regardless of the install method used — there's no
|
||||
way around that from the tooling side.
|
||||
on the device. `android/app/build.gradle.kts` picks a release signing key
|
||||
in this order: a local `android/key.properties` (gitignored — points at a
|
||||
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
|
||||
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
|
||||
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
|
||||
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
|
||||
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
|
||||
the `.aab` Play Console wants), then falls back to the debug key if neither
|
||||
is configured. As long as the same dedicated release keystore backs both
|
||||
`key.properties` locally and the Gitea secrets, local release builds and
|
||||
CI-built release APKs share one signature, so `adb install -r` works
|
||||
cleanly either way. A local checkout with no `key.properties` set up falls
|
||||
back to the (per-machine, ungitted) debug key, which won't match a
|
||||
CI-signed APK — installing one over the other still forces a full
|
||||
uninstall, since there's no way around Android's signature check from the
|
||||
tooling side.
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -98,3 +131,16 @@ Networking is deliberately split: `package:http` for simple JSON/XML
|
||||
request-response calls, `package:dio` only where streaming/progress is
|
||||
needed (downloads). Don't introduce a third HTTP client — extend the
|
||||
existing split instead.
|
||||
|
||||
## Play Store upload (CI)
|
||||
|
||||
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
|
||||
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
|
||||
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
|
||||
`internal` track as a **draft** (promote in Play Console, or edit
|
||||
`track`/`status` in the workflow). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||
repo secret (service account with release permissions; the app's first
|
||||
release must be uploaded manually). Play rejects a repeated `versionCode`, so
|
||||
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
|
||||
will fail the upload. Release notes come from
|
||||
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Styling
|
||||
|
||||
**Migration in progress:** the target look is
|
||||
[`design-system/DESIGN_SYSTEM.md`](design-system/DESIGN_SYSTEM.md) — warm
|
||||
neutrals, one violet accent, pill controls, Schibsted Grotesk/Instrument
|
||||
Sans, no gradients/shadows — built from the `widgets/noo/` component kit
|
||||
(see "Noo design-system components" below). Rebuilt so far: the app shell
|
||||
(top/bottom bars, drawer, sidebar, toolbar), Files/Offline, Photos,
|
||||
Favorites, Recent, Activity, Trash, Shares, Settings, the lock screen,
|
||||
login, `ShareUploadView`, `MoveCopyDestinationPicker`,
|
||||
`MoveCopyConflictSheet`, the shared `Breadcrumbs` widget, and the file
|
||||
details/share bottom sheets/dialogs (`DetailsSheet`/`ShareSheet`). **Still on
|
||||
the pre-rework Material 3 theme** documented in "Theme" and "Reusable
|
||||
chrome" below: `FileViewerScreen` (the media viewer), `SearchView`, and
|
||||
`LoginWebviewView`. Update this file to describe each area as it gets
|
||||
reworked, rather than leaving stale Material 3 guidance next to a design
|
||||
system that's already superseded it.
|
||||
|
||||
## Theme
|
||||
|
||||
All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart)
|
||||
@@ -14,10 +30,12 @@ widgets. Key points:
|
||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||
- **Font**: Inter via `google_fonts`, applied through
|
||||
`GoogleFonts.interTextTheme(...)`. In tests, set
|
||||
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
|
||||
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
|
||||
indefinitely (see `standards.md`).
|
||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
|
||||
app never contacts Google (the privacy policy on the website relies on
|
||||
this — keep it true; add new weights as bundled files). Tests set the
|
||||
same flag in `setUpAll` (see `standards.md`).
|
||||
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
||||
`surfaceContainerLow`.
|
||||
- **App bars**: flat, not centered, `surface` background.
|
||||
@@ -34,28 +52,263 @@ widgets. Key points:
|
||||
and no `scrollbarTheme` override (tried once, explicitly reverted). Don't
|
||||
reintroduce one without being asked.
|
||||
|
||||
## Noo design-system components
|
||||
|
||||
The rebuilt UI is assembled from `lib/widgets/noo/`, which implements
|
||||
`DESIGN_SYSTEM.md` §2–3. Build new screens from these rather than from raw
|
||||
Material widgets or the pre-rework chrome below.
|
||||
|
||||
- **Tokens** live in [`design_tokens.dart`](../../lib/theme/design_tokens.dart):
|
||||
colors via `context.nooColors` (a `NooColors` `ThemeExtension`), plus
|
||||
`NooText`, `NooSpace`, `NooRadii`, `NooSizes`, `NooMotion` and
|
||||
`nooDialogShadow`. `NooText` styles set no color; callers add it with
|
||||
`copyWith(color: ...)`. Flutter's `TextStyle.height` is a multiple of font
|
||||
size, so the spec's CSS line-height maps to it directly (for example,
|
||||
0.9 → `height: 0.9`).
|
||||
- **Icons are Lucide** (`lucide_icons_flutter`, `LucideIcons.*`) inside
|
||||
`noo/`, never Material `Icons.*`.
|
||||
- **Components are data-agnostic.** They take strings, icons, colors and
|
||||
callbacks, not `NextcloudItem`/`AppTab`, so the screen layer maps models
|
||||
onto them.
|
||||
- **Platform differences** are chosen with a flag rather than by reading the
|
||||
platform inside the widget: `NooNavStyle` (`ios`/`android`, with
|
||||
`NooNavStyle.fromPlatform`) for nav chrome, and `iosStyle` on rows and
|
||||
cards for the ellipsis vs. vertical-ellipsis overflow icon.
|
||||
|
||||
Catalog:
|
||||
|
||||
| Folder | Components |
|
||||
|---|---|
|
||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
||||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
|
||||
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
||||
|
||||
Gotchas:
|
||||
- `NooDrawer` can't set its own scrim. The host `Scaffold` needs
|
||||
`drawerScrimColor: context.nooColors.scrim`.
|
||||
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
|
||||
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
|
||||
do). Its `collapsible`/`initiallyExpanded` params (off by default) make
|
||||
`label` a tap target that shows/hides the card - no current caller opts
|
||||
in (Settings' mobile sections used to, when every section rendered
|
||||
inline in one long column; now each section is its own pushed screen -
|
||||
see `account_view.dart`'s doc comment - so there's nothing left to
|
||||
collapse). The params stay on the component itself since it's otherwise
|
||||
generic.
|
||||
- `NooSwipeAction` reveals its action on a normal swipe (tap the block to
|
||||
trigger it) but also fires it directly if the drag goes far enough (past
|
||||
1.8x the block's width) before release - a full swipe-through does delete
|
||||
in one gesture, it's not tap-only anymore.
|
||||
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
|
||||
built yet. `NooSidebar.windowControls` is the slot for it.
|
||||
- Always read colors through `context.nooColors`, never
|
||||
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
|
||||
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
|
||||
`.dark`), so anything painted from it carries a faint hue of whatever
|
||||
accent is picked instead of the app's neutral palette. `sticky_header_delegate.dart`
|
||||
shipped with this bug once already (a pinned controls-row header tinted
|
||||
by the accent instead of matching its screen's plain `colors.bg`).
|
||||
- `NooSegmentedControl` defaults to `onSurface: false` (`colors.surface`
|
||||
fill) - right when it's sitting directly on `colors.bg` (List/Grid
|
||||
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
||||
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
||||
track blends invisibly into the sheet instead of reading as a grouped
|
||||
control (Files'/Photos' sort and type-filter sheets do this).
|
||||
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
||||
on its scrollable child, or pull-to-refresh silently can't be triggered
|
||||
once the list is short enough to fit the viewport (empty, or one item) -
|
||||
every tab's `CustomScrollView` sets this explicitly for exactly that
|
||||
reason.
|
||||
|
||||
## Reusable chrome
|
||||
|
||||
**Rebuilt on the Noo design system** (Files/Offline, Photos, Favorites,
|
||||
Recent, Activity, Trash, Shares, Settings, the app shell, lock screen,
|
||||
login, `ShareUploadView`, `MoveCopyDestinationPicker`, `DetailsSheet` and
|
||||
`ShareSheet`): these no longer use the pieces below. Their own building
|
||||
blocks are noted where they matter:
|
||||
|
||||
- `FilesControlsRow` (`lib/widgets/files_controls_row.dart`) — now built from
|
||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||
Offline), and reused as-is by Favorites.
|
||||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||
trail Files uses in place of the shared
|
||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||
is now also noo-styled (same tokens, private-widget-turned-shared) — it's
|
||||
used only by `ShareUploadView`/`MoveCopyDestinationPicker`, which is why
|
||||
it was safe to restyle directly instead of forking another
|
||||
`FileBreadcrumbRow`-style copy; don't move Files back onto it.
|
||||
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
|
||||
`tab_location.dart`) — the loading/error/empty-state slivers and
|
||||
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
|
||||
`tab_state_slivers.dart` also has `topBarSliver`, which every regular
|
||||
tab's view uses to plant its own `AppTopBar` instance as the first sliver
|
||||
in its `CustomScrollView` (a thin wrapper around the framework's
|
||||
`SliverFloatingHeader`) instead of the shell passing one shared instance
|
||||
to `Scaffold.appBar` - see its doc comment and `architecture.md`'s
|
||||
"Mobile" bullet for why.
|
||||
`lib/widgets/settings/` — Settings' 8 section widgets plus
|
||||
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
|
||||
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
||||
`lib/widgets/shell/shell_common.dart` — account/storage formatting,
|
||||
`openSettings`/`openSearch`, `showAccountSwitcher`, `ShellAvatarButton`,
|
||||
`ShellSearchLauncher`, shared by the mobile and desktop shell chrome.
|
||||
- [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart)
|
||||
— the icon/color/thumbnail treatment for a file or folder, shared by any
|
||||
screen that lists `NextcloudItem`s the way the Files tab does (currently
|
||||
`files_view.dart` and `share_upload_view.dart`'s destination picker).
|
||||
Extend this rather than re-deriving per-type icons/colors at a new call
|
||||
site.
|
||||
— `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
|
||||
rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
|
||||
`NooFileTile`/`NooFileRow`/`NooFileTableRow`/`NooGridCard`'s `thumbnail`
|
||||
slot. `ShareUploadView`/`MoveCopyDestinationPicker` don't use any of the
|
||||
three any more - both destination pickers now list folders only (see
|
||||
below), and a folder never gets a real thumbnail (only image/video do),
|
||||
so a plain `NooFileTile(kind: NooFileKind.folder)` covers every row.
|
||||
- `DetailsSheet`/`ShareSheet` (`lib/widgets/details/details_sheet.dart`,
|
||||
`lib/widgets/share_sheet.dart`) are rebuilt: `showNooSheet`/`showNooDialog`
|
||||
per `NooLayout.isDesktop`, with the header built from `detailsFileTile`
|
||||
(a `NooFileTile` keyed by `NooFileKind.from`) and `detailsMetaLine` -
|
||||
shared top-level helpers in `details_sheet.dart` so both sheets open on
|
||||
the same header, replacing the old `DetailsHeader` widget. Neither sheet
|
||||
uses `showGradualBottomSheet`'s drag-to-resize any more: `DetailsSheet`
|
||||
swaps Info/Versions/Activity with a `NooSegmentedControl` (there's no
|
||||
tab-strip component in the noo kit) instead of a `TabBar`/`TabBarView`,
|
||||
and both sheets' content sits in one `Column` so `showNooSheet`'s/
|
||||
`showNooDialog`'s own `SingleChildScrollView` handles overflow - a fixed
|
||||
page per tab no longer needs a resizable sheet to see the rest.
|
||||
`showGradualBottomSheet` (`lib/widgets/gradual_bottom_sheet.dart`) has no
|
||||
remaining callers as a result.
|
||||
`ShareSheet`'s "Share with people"/"Share link"/"Send file directly"
|
||||
sections follow `DESIGN_SYSTEM.md` §4 via `NooShareSection`/
|
||||
`NooPersonAccessRow`/`NooPermissionPill`; the permission pill and the
|
||||
link's permission/expiry chips are read-only display (no
|
||||
`ItemOperations` call updates a share's permission/expiry/password/
|
||||
hide-download yet) - promote those to real controls once that exists.
|
||||
Removing a person/group/email share is reachable by tapping their
|
||||
permission pill, which opens a small "Remove access" menu.
|
||||
[`MoveCopyConflictSheet`](../../lib/widgets/move_copy_conflict_sheet.dart)
|
||||
is rebuilt: `showNooDialog`/`showNooSheet` (per `NooLayout.isDesktop`), a
|
||||
`NooGroupedList` of file-tile rows with an inline
|
||||
`NooSegmentedControl<ConflictChoice>` once "Decide per item" is picked,
|
||||
and `NooButton`s for overwrite-all/keep-both/decide-per-item/confirm. Its
|
||||
`show(BuildContext, List<MoveCopyConflict>)` API is unchanged.
|
||||
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||||
the blurred/translucent pill background shared by all floating chrome
|
||||
(bottom nav bar, media-viewer top/bottom bars and video transport
|
||||
controls). Reuse this for any new floating overlay instead of building a
|
||||
new blur/shadow combo.
|
||||
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
|
||||
main tab bar; opacity/blur are user-adjustable settings
|
||||
(`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull
|
||||
new adjustable visual knobs from the provider the same way rather than
|
||||
hardcoding them.
|
||||
the blurred/translucent pill background for the media viewer's top/bottom
|
||||
bars and video transport controls (its other former user, the floating
|
||||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||
overlay instead of building a new blur/shadow combo.
|
||||
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
|
||||
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
|
||||
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
|
||||
expanded always labelled. Phone/Android layout only.
|
||||
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
|
||||
out past `viewport.height / doc.height`, so for short documents (<= 3
|
||||
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
|
||||
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
|
||||
the boundary height reaches the viewport and fit-width stays reachable
|
||||
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
|
||||
+ top bar height, 0 when the controls are hidden) animates the viewport
|
||||
below the overlaid top bar.
|
||||
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
|
||||
under the label, above the card; `SettingsSection` uses it for its
|
||||
`notice` on phones.
|
||||
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
|
||||
the media viewer's bottom bar as a draggable panel (mobile/phone only;
|
||||
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
|
||||
Details button, or swiping up on swipeable media (raw `Listener` in
|
||||
`file_viewer_screen.dart`) grows it upward so the action row rides on top
|
||||
of the details; tapping the media collapses it.
|
||||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||
are gone: the bottom bar is now the flat, non-blurred `NooBottomBar` (no
|
||||
opacity/blur knob - flat surfaces per the design system), grid tiles are
|
||||
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
|
||||
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
|
||||
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||||
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||||
don't confuse the two: this one is still non-blurred, just inset with a
|
||||
`line` border and `nooDialogShadow` (the app's one other shadow user - see
|
||||
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||||
either). The host `Scaffold` needs `extendBody: true` while it's active
|
||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||
which also means every tab's own scrollable list has to reserve enough
|
||||
bottom padding to clear the bar - nothing does that automatically once the
|
||||
body draws behind it. Use `bottomBarClearance(context)`
|
||||
(`tab_state_slivers.dart`) for that rather than a flat `100`; it already
|
||||
accounts for both bar styles.
|
||||
Settings' separate "Search in bottom bar" toggle
|
||||
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search
|
||||
entry to either bar style (`NooBottomBar`'s `searchDestination`/
|
||||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||
anywhere that needs the *current* cap on regular tabs.
|
||||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
||||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
||||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
||||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
||||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
||||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
||||
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
||||
the drawer. The menu header has a chevron that expands an account section
|
||||
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
||||
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||
not a modal flow) - there's no existing anchored-popup primitive here
|
||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
||||
inset, not the top bar's full height on top of that) so it covers the
|
||||
top bar - including the tab title - rather than sitting below it, and
|
||||
its card carries two stacked `boxShadow`s rather than just
|
||||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
||||
to read as basically invisible on a small card over a dark theme's
|
||||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
||||
edge to be visible against an already-dark backdrop), so a second,
|
||||
tighter, more opaque contact shadow underneath it gives real elevation
|
||||
in both themes. The card's border can go missing wherever an opaque row
|
||||
sits against it too (every corner but the header's, which has no
|
||||
full-bleed fill of its own) if a `Container` combines `border` with its
|
||||
own `clipBehavior` - that paints the border as part of the *outer*
|
||||
decoration, then the clipped child on top right up to the same boundary,
|
||||
with no gap for the border's own stroke to show through. Fixed the same
|
||||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
||||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
||||
reduced by that same 1px) around the filled, clipped content instead, so
|
||||
it never paints over the border. Also added
|
||||
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||||
second search row under the way iOS's `search:` slot does, so an inline
|
||||
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||||
search isn't in the bottom bar) sits to the title's own right in that
|
||||
flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/
|
||||
ellipsizes if there's truly no room but doesn't claim more than it needs
|
||||
otherwise - instead of the title being replaced by it.
|
||||
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
|
||||
`architecture.md`); also where the pull-to-refresh gesture thresholds and
|
||||
the classic Material refresh spinner live.
|
||||
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
||||
chip and pull-to-refresh gesture/spinner. Every screen (including
|
||||
`ShareUploadView`/`MoveCopyDestinationPicker`, its last two users) has
|
||||
dropped it for a plain `RefreshIndicator` + `CustomScrollView`
|
||||
(device-sync status now shows per-row via `NooStatusIcon`/the Offline
|
||||
`NooSummaryCard`, not a shared header chip), so the `SyncedHeaderScaffold`
|
||||
class itself is now dead code - kept only because the same file's
|
||||
top-level `formatBytes` helper is still widely used
|
||||
(`files_view.dart`/`favorites_view.dart`/`shell_common.dart`/
|
||||
`widgets/details/*`).
|
||||
- [`SegmentedIconGroup`/`ToggleIconButton`](../../lib/widgets/segmented_icon_toggle.dart)
|
||||
and [`SortMenuButton`](../../lib/widgets/sort_menu_button.dart) — the
|
||||
Material sort/filter-chip pieces `ShareUploadView`/
|
||||
`MoveCopyDestinationPicker` used to mirror Files' old controls row with.
|
||||
Both destination pickers dropped that whole row (they only ever browse
|
||||
folders, so sort/hidden/scope/type-filter/grid controls don't apply), so
|
||||
these two files are now dead code too - `Breadcrumbs` is the only shared
|
||||
widget promoted to noo styling instead of removed, since Files' own
|
||||
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
||||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||
for the video player's seek bar, plus a perpetually-animated
|
||||
@@ -93,4 +346,32 @@ widgets. Key points:
|
||||
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
|
||||
reads correctly in both light and dark mode. Never the full-color
|
||||
`app_icon.png`/adaptive-icon assets for in-app UI — those are for the
|
||||
launcher icon only (`flutter_launcher_icons` in `pubspec.yaml`).
|
||||
launcher icon only. The launcher icon is maintained by hand from an
|
||||
IconKitchen export (`mipmap-*` in `android/app/src/main/res`); there is no
|
||||
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
||||
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
||||
layer), so it renders at a sensible size at 72-80px.
|
||||
|
||||
## Text/markdown viewer
|
||||
|
||||
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||
|
||||
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
||||
|
||||
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||
|
||||
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||
|
||||
## Desktop/tablet layout notes
|
||||
|
||||
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
||||
system bars clear of the sidebar; the sidebar has no divider between pinned
|
||||
and hidden tabs.
|
||||
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
||||
pushed category screens render the desktop card layout.
|
||||
- Settings that don't apply in wide-tablet (Appearance nav options,
|
||||
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
||||
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
||||
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
||||
width, never below the phone count), so a tablet in portrait gets more
|
||||
columns rather than a few huge tiles.
|
||||
|
||||
@@ -43,8 +43,22 @@ jobs:
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Decode release keystore
|
||||
# Keeps every release build - regardless of which runner built it -
|
||||
# signed with the same key, so downloaded updates install cleanly
|
||||
# over a previous release instead of failing with "App not
|
||||
# installed" (mismatched signature). See android/app/build.gradle.kts.
|
||||
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
|
||||
env:
|
||||
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
|
||||
|
||||
- name: Build release APK
|
||||
run: flutter build apk --release
|
||||
env:
|
||||
RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/release-keystore.jks
|
||||
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
|
||||
- name: Rename APK to Noo-<version>.apk
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Build App Bundle
|
||||
|
||||
# Deliberately not "on every push" - only when a real release tag (Release-1.0.0,
|
||||
# Release-1.2.3, ...) is pushed. Tag the commit you want built:
|
||||
# git tag Release-1.0.0 && git push origin Release-1.0.0
|
||||
# Unlike the RC* flow (build.yml), this builds the .aab Play Console wants,
|
||||
# not a sideloadable .apk.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "Release-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
# Google removed the legacy standalone "tools" package from the
|
||||
# SDK repository (~Sep 2026); the action's own default packages
|
||||
# list ("tools platform-tools") now makes its internal sdkmanager
|
||||
# call fail for everyone. platform-tools is all this step needs
|
||||
# to install itself - flutter build fetches whatever
|
||||
# platform/build-tools versions it actually needs on its own.
|
||||
packages: "platform-tools"
|
||||
|
||||
- name: Check tag matches pubspec version
|
||||
# Play rejects a repeated versionCode and a mismatched tag would
|
||||
# publish the wrong version name, so fail fast before building.
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then
|
||||
echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze
|
||||
|
||||
- name: Decode release keystore
|
||||
# Same key as the RC* APK builds, so a Play Store upload's signature
|
||||
# matches anything sideloaded from a release build. See
|
||||
# android/app/build.gradle.kts.
|
||||
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
|
||||
env:
|
||||
RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
|
||||
|
||||
- name: Build release App Bundle
|
||||
run: flutter build appbundle --release
|
||||
env:
|
||||
RELEASE_KEYSTORE_PATH: ${{ runner.temp }}/release-keystore.jks
|
||||
RELEASE_KEYSTORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
|
||||
- name: Rename bundle to Noo-<version>.aab
|
||||
run: |
|
||||
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||
mkdir -p release
|
||||
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
|
||||
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Publish Gitea release with App Bundle attached
|
||||
# Gitea-native release action (not GitHub's) - authenticates and
|
||||
# talks to this same Gitea instance automatically via the
|
||||
# `github.token`/`github.server_url` context Gitea Actions
|
||||
# provides for compatibility. Release is named/tagged after
|
||||
# whatever tag triggered this run (e.g. "Release-1.0.0").
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: ${{ env.AAB_PATH }}
|
||||
|
||||
- name: Upload to Google Play (internal track, draft)
|
||||
# Stages the build only; promote it in Play Console, or change
|
||||
# `status` to "completed" (and/or `track`) to roll out automatically.
|
||||
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
|
||||
# first release must already have been uploaded by hand.
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: dev.ayushya.noo
|
||||
releaseFiles: ${{ env.AAB_PATH }}
|
||||
releaseName: ${{ env.VERSION }}
|
||||
track: internal
|
||||
status: draft
|
||||
whatsNewDirectory: distribution/whatsnew
|
||||
@@ -44,3 +44,4 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@ relevant file(s) before working in that area rather than loading all of them:
|
||||
conventions, fonts, reusable chrome widgets
|
||||
- [`standards.md`](.claude/context/standards.md) — code style, comment
|
||||
conventions, linting, testing
|
||||
- [`design-system/DESIGN_SYSTEM.md`](.claude/context/design-system/DESIGN_SYSTEM.md)
|
||||
— the target visual/component spec (tokens, components, platform
|
||||
mapping, screen recipes) for the in-progress UI rework; the sibling
|
||||
`.dc.html`/`noo-kit.js` files it references are the visual/token
|
||||
reference builds, not something to read directly
|
||||
|
||||
## Common commands
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ gradle-wrapper.jar
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
/build/
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
@@ -12,3 +13,4 @@ GeneratedPluginRegistrant.java
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
RELEASE_SIGNING_SECRETS.txt
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
// Release signing: prefer a local android/key.properties (gitignored, for
|
||||
// a developer machine that has the release keystore), then CI env vars
|
||||
// (set by .gitea/workflows/build.yml from repo secrets), then null - which
|
||||
// falls back to debug signing below so an unconfigured checkout still
|
||||
// builds/runs fine, same as before this existed.
|
||||
val releaseKeystoreProperties = Properties().apply {
|
||||
val propsFile = rootProject.file("key.properties")
|
||||
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
||||
}
|
||||
|
||||
fun releaseSigningValue(propertyKey: String, envKey: String): String? =
|
||||
releaseKeystoreProperties.getProperty(propertyKey) ?: System.getenv(envKey)
|
||||
|
||||
val releaseStoreFile = releaseSigningValue("storeFile", "RELEASE_KEYSTORE_PATH")
|
||||
val releaseStorePassword = releaseSigningValue("storePassword", "RELEASE_KEYSTORE_PASSWORD")
|
||||
val releaseKeyAlias = releaseSigningValue("keyAlias", "RELEASE_KEY_ALIAS")
|
||||
val releaseKeyPassword = releaseSigningValue("keyPassword", "RELEASE_KEY_PASSWORD")
|
||||
val hasReleaseSigning = listOf(
|
||||
releaseStoreFile,
|
||||
releaseStorePassword,
|
||||
releaseKeyAlias,
|
||||
releaseKeyPassword,
|
||||
).all { !it.isNullOrBlank() }
|
||||
|
||||
android {
|
||||
namespace = "dev.ayushya.noo"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -27,11 +53,27 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = file(releaseStoreFile!!)
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Falls back to the debug keystore (so an unconfigured
|
||||
// checkout can still `flutter run --release`) when no release
|
||||
// signing config is available - see the comment above.
|
||||
signingConfig = if (hasReleaseSigning) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +84,18 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Device sync's periodic/one-off background jobs (SyncWorker,
|
||||
// ConflictResolveWorker) - see their doc comments for why this is
|
||||
// plain WorkManager rather than a Dart-side background-task plugin.
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
// PROPFIND (WebDAV directory listing) - Android's HttpURLConnection
|
||||
// hard-rejects any method outside {OPTIONS,GET,HEAD,POST,PUT,DELETE,
|
||||
// TRACE,PATCH} (ProtocolException), unlike plain OpenJDK. OkHttp has
|
||||
// no such whitelist. See SyncEngine.kt.
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Device sync (SyncWorker/ConflictResolveWorker) - WorkManager instantiates
|
||||
# a lot of its own internals via reflection (Workers by class name recorded
|
||||
# at enqueue time, its bundled Room database's *_Impl class off the
|
||||
# abstract database class's own name, its default InputMerger, etc). R8's
|
||||
# member-level shrinking silently breaks any of these - e.g. stripping an
|
||||
# "unused" no-arg constructor - even when the class itself survives a
|
||||
# plain `-keep class` with no wildcard, and narrowly keeping only the
|
||||
# classes hit by one test pass just means the next reflection path (a
|
||||
# different WorkManager-internal class) breaks instead. Two separate
|
||||
# instances of this already bit real testing (WorkDatabase, then
|
||||
# OverwritingInputMerger) before landing on this broad keep - see
|
||||
# android/app/build.gradle.kts (androidx.work dependency) and
|
||||
# .claude/context/server.md's "Device sync" section.
|
||||
-keep class androidx.work.** { *; }
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-keep class dev.ayushya.noo.SyncWorker { *; }
|
||||
-keep class dev.ayushya.noo.ConflictResolveWorker { *; }
|
||||
-keep class dev.ayushya.noo.SyncConflictReceiver { *; }
|
||||
@@ -2,12 +2,23 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- For local_auth's biometric app-lock (Settings > Security). -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||
<!-- For ShareUploadService, which keeps a "Share to Noo" upload going
|
||||
(and cancellable from its notification) even if the app is fully
|
||||
closed - see the service's own doc comment. -->
|
||||
<!-- For connectivity_plus (ConnectivityController), which gates the
|
||||
Offline tab restriction and skips network requests entirely while
|
||||
offline - see session_controller.dart/main.dart. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<!-- For ShareUploadService/DownloadService, which keep a "Share to Noo"
|
||||
upload/download going (and cancellable from its notification) even
|
||||
if the app is fully closed - see each service's own doc comment. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<!-- DownloadService's pre-Android-10 fallback only (MediaStore.Downloads
|
||||
needs no runtime permission on API 29+, which is what targetSdk
|
||||
actually enforces) - capped with maxSdkVersion so it's not even
|
||||
requested on newer devices. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"/>
|
||||
<application
|
||||
android:label="Noo"
|
||||
android:name="${applicationName}"
|
||||
@@ -49,11 +60,45 @@
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
<!-- Lets Noo be picked as a source ("Google Photos", "Files", ...)
|
||||
from another app's own file/photo picker chooser - handled by
|
||||
MainActivity.kt/PickIntentService, not a plugin. A single
|
||||
"*/*" filter matches any mimeType the caller requests
|
||||
(image/*, video/*, or a generic file pick). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.GET_CONTENT"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.OPENABLE"/>
|
||||
<data android:mimeType="*/*"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".ShareUploadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"/>
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync"/>
|
||||
<!-- The "Keep local"/"Use server" actions on a sync-conflict
|
||||
notification (SyncWorker/SyncEngine) - must be a manifest-
|
||||
registered receiver, not a runtime-registered one, since it
|
||||
needs to work even when the app process isn't running. -->
|
||||
<receiver
|
||||
android:name=".SyncConflictReceiver"
|
||||
android:exported="false"/>
|
||||
<!-- Hands back the file(s) a caller picked via the GET_CONTENT filter
|
||||
above - only the app's own cache/picker/ subfolder is exposed,
|
||||
see file_paths.xml. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.picker.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Resolves one sync conflict, triggered either by [SyncConflictReceiver]
|
||||
* (a notification action) or directly from Dart (in-app resolution via the
|
||||
* `dev.ayushya.noo/sync_service` MethodChannel's `resolveConflict`, see
|
||||
* `MainActivity.kt`) - "local" pushes the on-device copy up (overwriting
|
||||
* the server), "server" pulls the server copy down (overwriting the local
|
||||
* mirror). Either way, the sync-state entry for this file is refreshed
|
||||
* afterward so the next [SyncWorker] pass doesn't immediately re-flag it.
|
||||
*/
|
||||
class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(appContext, params) {
|
||||
|
||||
companion object {
|
||||
const val KEY_ACCOUNT_ID = "accountId"
|
||||
const val KEY_SERVER_URL = "serverUrl"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_AUTH_HEADER = "authHeader"
|
||||
const val KEY_FILE_ID = "fileId"
|
||||
const val KEY_REMOTE_PATH = "remotePath"
|
||||
const val KEY_REL_PATH = "relPath"
|
||||
const val KEY_RESOLUTION = "resolution"
|
||||
|
||||
/** The one place a resolution gets enqueued - both callers above share it. */
|
||||
fun enqueue(
|
||||
context: Context,
|
||||
accountId: String?,
|
||||
serverUrl: String?,
|
||||
username: String?,
|
||||
authHeader: String?,
|
||||
fileId: String?,
|
||||
remotePath: String?,
|
||||
relPath: String?,
|
||||
resolution: String?,
|
||||
) {
|
||||
val data = workDataOf(
|
||||
KEY_ACCOUNT_ID to accountId,
|
||||
KEY_SERVER_URL to serverUrl,
|
||||
KEY_USERNAME to username,
|
||||
KEY_AUTH_HEADER to authHeader,
|
||||
KEY_FILE_ID to fileId,
|
||||
KEY_REMOTE_PATH to remotePath,
|
||||
KEY_REL_PATH to relPath,
|
||||
KEY_RESOLUTION to resolution,
|
||||
)
|
||||
val request = OneTimeWorkRequestBuilder<ConflictResolveWorker>()
|
||||
.setInputData(data)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
// Same lock as SyncWorker: both load/mutate/save the whole state map.
|
||||
SyncEngine.syncLock.withLock { performResolve() }
|
||||
}
|
||||
|
||||
private suspend fun performResolve(): Result = withContext(Dispatchers.IO) {
|
||||
val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure()
|
||||
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure()
|
||||
val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure()
|
||||
val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure()
|
||||
val fileId = inputData.getString(KEY_FILE_ID) ?: return@withContext Result.failure()
|
||||
val remotePath = inputData.getString(KEY_REMOTE_PATH) ?: return@withContext Result.failure()
|
||||
val relPath = inputData.getString(KEY_REL_PATH) ?: return@withContext Result.failure()
|
||||
val resolution = inputData.getString(KEY_RESOLUTION) ?: return@withContext Result.failure()
|
||||
|
||||
val syncRoot = SyncEngine.syncRoot(applicationContext, accountId)
|
||||
val localFile = File(syncRoot, relPath)
|
||||
val ok = when (resolution) {
|
||||
"local" -> localFile.exists() &&
|
||||
SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile)
|
||||
"server" -> SyncEngine.downloadFile(serverUrl, username, authHeader, remotePath, localFile)
|
||||
else -> false
|
||||
}
|
||||
if (!ok) return@withContext Result.retry()
|
||||
|
||||
// Refresh the recorded state from the server's post-resolution
|
||||
// etag, so this file isn't immediately re-flagged as a conflict on
|
||||
// the next sync pass.
|
||||
val fresh = runCatching {
|
||||
SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath)
|
||||
}.getOrNull()
|
||||
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
|
||||
state[fileId] = SyncEngine.FileState(
|
||||
relPath = relPath,
|
||||
etag = fresh?.etag ?: "",
|
||||
lastModified = fresh?.lastModified ?: 0L,
|
||||
size = localFile.length(),
|
||||
localMTime = localFile.lastModified(),
|
||||
)
|
||||
SyncEngine.saveState(applicationContext, accountId, state)
|
||||
SyncStatusBus.removeConflict(accountId, fileId)
|
||||
|
||||
Result.success()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import java.io.File
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Shared low-level WebDAV GET/PUT primitives (plain `HttpURLConnection`, no
|
||||
* new HTTP dependency) - used by [DownloadService], [ShareUploadService],
|
||||
* and [SyncEngine], which each independently re-implemented this same
|
||||
* ~20-line connect/stream/disconnect shape before this existed. Progress
|
||||
* reporting and cancellation are optional: the two foreground Services
|
||||
* pass both (user-visible, cancellable transfers); [SyncEngine]'s
|
||||
* background callers pass neither.
|
||||
*
|
||||
* Deliberately does *not* know about `OCS-APIRequest` - that header is for
|
||||
* OCS REST endpoints (shares/activity/quota), not plain WebDAV GET/PUT,
|
||||
* and was previously sent by mistake on some of these calls; dropped here
|
||||
* so every caller gets the same (correct) minimal header set.
|
||||
*/
|
||||
object DavTransfer {
|
||||
const val CONNECT_TIMEOUT_MS = 15000
|
||||
const val READ_TIMEOUT_MS = 30000
|
||||
const val UPLOAD_READ_TIMEOUT_MS = 60000
|
||||
private const val BUFFER_SIZE = 256 * 1024
|
||||
private const val PROGRESS_THROTTLE_MS = 200
|
||||
|
||||
/** GETs [url] into [destination] (a plain file), creating parent dirs as needed. */
|
||||
fun get(
|
||||
url: URL,
|
||||
authHeader: String,
|
||||
destination: File,
|
||||
onProgress: ((received: Long, total: Long?) -> Unit)? = null,
|
||||
isCancelled: (() -> Boolean)? = null,
|
||||
): Boolean {
|
||||
destination.parentFile?.mkdirs()
|
||||
return getInto(url, authHeader, { destination.outputStream() }, onProgress, isCancelled)
|
||||
}
|
||||
|
||||
/**
|
||||
* GETs [url] into whatever [openOutput] returns - a plain `File` output
|
||||
* stream or, for [DownloadService]'s MediaStore-staged entries, a
|
||||
* `ContentResolver`-provided one the caller owns.
|
||||
*/
|
||||
fun getInto(
|
||||
url: URL,
|
||||
authHeader: String,
|
||||
openOutput: () -> OutputStream?,
|
||||
onProgress: ((received: Long, total: Long?) -> Unit)? = null,
|
||||
isCancelled: (() -> Boolean)? = null,
|
||||
): Boolean {
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.setRequestProperty("Authorization", authHeader)
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
connection.connect()
|
||||
if (connection.responseCode !in 200..299) return false
|
||||
|
||||
val total = connection.contentLengthLong.takeIf { it > 0 }
|
||||
val output = openOutput() ?: return false
|
||||
output.use { out ->
|
||||
connection.inputStream.use { input ->
|
||||
copyWithProgress(input, out, total, onProgress, isCancelled)
|
||||
}
|
||||
}
|
||||
isCancelled?.invoke() != true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** PUTs [source]'s bytes to [url]. */
|
||||
fun put(
|
||||
url: URL,
|
||||
authHeader: String,
|
||||
source: File,
|
||||
contentType: String? = null,
|
||||
onProgress: ((sent: Long, total: Long) -> Unit)? = null,
|
||||
isCancelled: (() -> Boolean)? = null,
|
||||
): Boolean {
|
||||
val length = source.length()
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "PUT"
|
||||
connection.doOutput = true
|
||||
connection.setFixedLengthStreamingMode(length)
|
||||
connection.setRequestProperty("Authorization", authHeader)
|
||||
if (contentType != null) connection.setRequestProperty("Content-Type", contentType)
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
connection.readTimeout = UPLOAD_READ_TIMEOUT_MS
|
||||
|
||||
connection.outputStream.use { output ->
|
||||
source.inputStream().use { input ->
|
||||
copyWithProgress(input, output, length, { sent, total ->
|
||||
onProgress?.invoke(sent, total ?: length)
|
||||
}, isCancelled)
|
||||
}
|
||||
}
|
||||
if (isCancelled?.invoke() == true) {
|
||||
false
|
||||
} else {
|
||||
connection.responseCode in 200..299
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyWithProgress(
|
||||
input: java.io.InputStream,
|
||||
output: OutputStream,
|
||||
total: Long?,
|
||||
onProgress: ((Long, Long?) -> Unit)?,
|
||||
isCancelled: (() -> Boolean)?,
|
||||
) {
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var transferred = 0L
|
||||
var lastEmit = 0L
|
||||
while (isCancelled?.invoke() != true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
transferred += read
|
||||
if (onProgress != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastEmit >= PROGRESS_THROTTLE_MS) {
|
||||
lastEmit = now
|
||||
onProgress(transferred, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.IBinder
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Foreground service that downloads one or more files over WebDAV straight
|
||||
* into the device's public Downloads folder, independent of MainActivity/
|
||||
* the Flutter engine being alive - the download counterpart of
|
||||
* ShareUploadService.kt (see its doc comment: only a real Android Service
|
||||
* survives the app being closed mid-transfer, the same guarantee a real
|
||||
* file-manager app's download notification gives you). GET itself is
|
||||
* [DavTransfer.getInto]; this file owns only what's specific to
|
||||
* downloading - MediaStore staging and the notification/queue plumbing.
|
||||
*
|
||||
* Started via the `dev.ayushya.noo/download_service` MethodChannel
|
||||
* (MainActivity.kt). Shows one persistent, cancellable notification for
|
||||
* the whole batch; a second batch arriving mid-download queues behind the
|
||||
* first (see [TransferQueue]) instead of being dropped.
|
||||
*/
|
||||
class DownloadService : Service() {
|
||||
companion object {
|
||||
const val ACTION_CANCEL = "dev.ayushya.noo.action.CANCEL_DOWNLOAD"
|
||||
const val EXTRA_FILES = "files" // JSON array of {path, name, mimeType, size}
|
||||
const val EXTRA_SERVER_URL = "serverUrl"
|
||||
const val EXTRA_USERNAME = "username"
|
||||
const val EXTRA_AUTH_HEADER = "authHeader"
|
||||
|
||||
private const val CHANNEL_ID = "file_downloads_v2"
|
||||
private const val NOTIFICATION_ID = 4301
|
||||
}
|
||||
|
||||
private data class DownloadFile(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val mimeType: String?,
|
||||
val size: Long?,
|
||||
)
|
||||
|
||||
private data class DownloadBatch(
|
||||
val files: List<DownloadFile>,
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
val authHeader: String,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var cancelled = false
|
||||
private val queue = TransferQueue<DownloadBatch> { runDownloads(it) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
cancelled = true
|
||||
queue.clearPending()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val filesJson = intent?.getStringExtra(EXTRA_FILES)
|
||||
val serverUrl = intent?.getStringExtra(EXTRA_SERVER_URL)
|
||||
val username = intent?.getStringExtra(EXTRA_USERNAME)
|
||||
val authHeader = intent?.getStringExtra(EXTRA_AUTH_HEADER)
|
||||
if (filesJson == null || serverUrl == null || username == null || authHeader == null) {
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
NooNotificationChannels.ensure(
|
||||
this,
|
||||
CHANNEL_ID,
|
||||
"File downloads",
|
||||
// Default (not low) importance so a finished download actually
|
||||
// alerts; the progress updates themselves stay silent below.
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
"Progress and completion of files downloaded from Noo",
|
||||
legacyIds = listOf("file_downloads"),
|
||||
)
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
buildProgressNotification("Preparing to download…", null, indeterminate = true),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
|
||||
queue.enqueue(DownloadBatch(parseFiles(filesJson), serverUrl, username, authHeader))
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun parseFiles(json: String): List<DownloadFile> {
|
||||
val arr = JSONArray(json)
|
||||
return (0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
DownloadFile(
|
||||
path = obj.getString("path"),
|
||||
name = obj.getString("name"),
|
||||
mimeType = if (obj.isNull("mimeType")) null else obj.getString("mimeType"),
|
||||
size = if (obj.isNull("size")) null else obj.getLong("size"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runDownloads(batch: DownloadBatch) {
|
||||
cancelled = false
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
val cleanServer = batch.serverUrl.trimEnd('/')
|
||||
|
||||
for ((index, file) in batch.files.withIndex()) {
|
||||
if (cancelled) break
|
||||
val label = if (batch.files.size == 1) {
|
||||
file.name
|
||||
} else {
|
||||
"${file.name} (${index + 1}/${batch.files.size})"
|
||||
}
|
||||
try {
|
||||
var cleanPath = file.path.trim()
|
||||
if (!cleanPath.startsWith("/")) cleanPath = "/$cleanPath"
|
||||
val encodedPath = cleanPath.split("/").joinToString("/") { Uri.encode(it) }
|
||||
val url = URL("$cleanServer/remote.php/dav/files/${batch.username}$encodedPath")
|
||||
val ok = downloadFile(file, url, batch.authHeader) { received, total ->
|
||||
notify(
|
||||
buildProgressNotification(
|
||||
"Downloading $label…",
|
||||
progressFraction(received, total),
|
||||
indeterminate = total == null,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (ok) succeeded++ else failed++
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
val finalText = when {
|
||||
cancelled -> "Download cancelled"
|
||||
failed == 0 && batch.files.size == 1 -> "Downloaded ${batch.files.first().name}"
|
||||
failed == 0 -> "Downloaded $succeeded of ${batch.files.size} files"
|
||||
else -> "Downloaded $succeeded of ${batch.files.size} files - $failed failed"
|
||||
}
|
||||
manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText))
|
||||
|
||||
if (queue.pendingCount == 0) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressFraction(received: Long, total: Long?): Float? {
|
||||
if (total == null || total <= 0) return null
|
||||
return (received.toFloat() / total.toFloat()).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
/// Streams the GET response straight into the public Downloads
|
||||
/// collection via MediaStore (API 29+) - scoped storage means a
|
||||
/// background Service can't prompt a SAF picker the way `file_saver`
|
||||
/// does from the Flutter/Activity side, so this writes directly to the
|
||||
/// Downloads collection instead, same place a browser download lands.
|
||||
/// Pre-Q devices fall back to the legacy public Downloads directory.
|
||||
private fun downloadFile(
|
||||
file: DownloadFile,
|
||||
url: URL,
|
||||
authHeader: String,
|
||||
onProgress: (Long, Long?) -> Unit,
|
||||
): Boolean {
|
||||
val mimeType = file.mimeType ?: "application/octet-stream"
|
||||
var outputUri: Uri? = null
|
||||
val ok = DavTransfer.getInto(
|
||||
url,
|
||||
authHeader,
|
||||
openOutput = {
|
||||
val uri = createDownloadsEntry(file.name, mimeType)
|
||||
if (uri != null) {
|
||||
outputUri = uri
|
||||
contentResolver.openOutputStream(uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
},
|
||||
onProgress = { received, total -> onProgress(received, file.size ?: total) },
|
||||
isCancelled = { cancelled },
|
||||
)
|
||||
|
||||
outputUri?.let { uri ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
|
||||
contentResolver.update(uri, values, null, null)
|
||||
}
|
||||
if (!ok) contentResolver.delete(uri, null, null)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
private fun createDownloadsEntry(name: String, mimeType: String): Uri? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val downloadsDir =
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
if (!downloadsDir.exists()) downloadsDir.mkdirs()
|
||||
Uri.fromFile(File(downloadsDir, name))
|
||||
}
|
||||
}
|
||||
|
||||
private fun manager() =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
private fun notify(notification: Notification) {
|
||||
manager().notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun buildProgressNotification(
|
||||
text: String,
|
||||
progress: Float?,
|
||||
indeterminate: Boolean,
|
||||
): Notification {
|
||||
val cancelIntent = Intent(this, DownloadService::class.java).apply { action = ACTION_CANCEL }
|
||||
val cancelPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
cancelIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle("Downloading from Noo")
|
||||
.setContentText(text)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
|
||||
when {
|
||||
indeterminate -> builder.setProgress(100, 0, true)
|
||||
progress != null -> builder.setProgress(100, (progress * 100).toInt(), false)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildFinalNotification(text: String): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Noo")
|
||||
.setContentText(text)
|
||||
.setOngoing(false)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelled = true
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
@@ -10,11 +11,23 @@ import android.os.Looper
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
// FlutterFragmentActivity (not the default FlutterActivity) is required by
|
||||
// local_auth's Android implementation, which hosts its biometric/device
|
||||
@@ -38,10 +51,23 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
private val shareIntentChannelName = "dev.ayushya.noo/share_intent"
|
||||
private val newShareChannelName = "dev.ayushya.noo/share_intent/new"
|
||||
private val uploadServiceChannelName = "dev.ayushya.noo/upload_service"
|
||||
private val downloadServiceChannelName = "dev.ayushya.noo/download_service"
|
||||
private val pickIntentChannelName = "dev.ayushya.noo/pick_intent"
|
||||
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
|
||||
private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
|
||||
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
|
||||
private val uploadStatusChannelName = "dev.ayushya.noo/upload_service/status"
|
||||
private val notificationPermissionRequestCode = 4202
|
||||
// Lazy, not a field initializer - `packageName` reads through the
|
||||
// Activity's base Context, which isn't attached yet while this class's
|
||||
// fields are being constructed (crashes with an NPE if evaluated then).
|
||||
private val pickerFileProviderAuthority by lazy { "$packageName.picker.fileprovider" }
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var newShareSink: EventChannel.EventSink? = null
|
||||
private var newPickSink: EventChannel.EventSink? = null
|
||||
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
|
||||
private var uploadCompletedListener: ((UploadEventBus.Completed) -> Unit)? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -73,6 +99,145 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadServiceChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"startDownload" -> {
|
||||
startDownloadService(call, result)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, pickIntentChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getPickRequest" -> result.success(extractPickRequest(intent))
|
||||
"finishPick" -> finishPick(call, result)
|
||||
"cancelPick" -> cancelPick(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, newPickChannelName)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
newPickSink = events
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
newPickSink = null
|
||||
}
|
||||
})
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, syncServiceChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"reschedule" -> rescheduleSyncWork(call, result)
|
||||
"cancel" -> cancelSyncWork(call, result)
|
||||
"syncNow" -> syncNow(call, result)
|
||||
"getSyncStatus" -> result.success(
|
||||
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
|
||||
)
|
||||
"resolveConflict" -> resolveConflict(call, result)
|
||||
"removeLocalSync" -> removeLocalSync(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, syncStatusChannelName)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
val listener: (SyncStatusBus.Status) -> Unit = { status ->
|
||||
mainHandler.post { events.success(syncStatusMap(status)) }
|
||||
}
|
||||
syncStatusListener = listener
|
||||
SyncStatusBus.subscribe(listener)
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
syncStatusListener?.let { SyncStatusBus.unsubscribe(it) }
|
||||
syncStatusListener = null
|
||||
}
|
||||
})
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, uploadStatusChannelName)
|
||||
.setStreamHandler(object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
val listener: (UploadEventBus.Completed) -> Unit = { completed ->
|
||||
mainHandler.post {
|
||||
events.success(
|
||||
mapOf(
|
||||
"remoteFolder" to completed.remoteFolder,
|
||||
"succeeded" to completed.succeeded,
|
||||
"failed" to completed.failed,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
uploadCompletedListener = listener
|
||||
UploadEventBus.subscribe(listener)
|
||||
}
|
||||
override fun onCancel(arguments: Any?) {
|
||||
uploadCompletedListener?.let { UploadEventBus.unsubscribe(it) }
|
||||
uploadCompletedListener = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// [accountIdOverride] is the account Dart is asking about - needed
|
||||
/// because [SyncStatusBus] only learns an account once a sync pass has
|
||||
/// run in this process, so on a fresh app start its `accountId` is null
|
||||
/// and the durable per-file synced state (which is what actually says
|
||||
/// "this file is mirrored") would otherwise be reported as empty.
|
||||
private fun syncStatusMap(
|
||||
status: SyncStatusBus.Status,
|
||||
accountIdOverride: String? = null,
|
||||
): Map<String, Any?> {
|
||||
val accountId = accountIdOverride ?: status.accountId
|
||||
// Live syncing/conflict state on the bus belongs to whichever
|
||||
// account last synced - don't attribute it to a different one.
|
||||
val busMatches = status.accountId == null || status.accountId == accountId
|
||||
val syncedFileIds = accountId?.let {
|
||||
SyncEngine.loadState(applicationContext, it).keys.toList()
|
||||
} ?: emptyList()
|
||||
return mapOf(
|
||||
"accountId" to accountId,
|
||||
"syncing" to (busMatches && status.syncing),
|
||||
"syncingFileIds" to if (busMatches) status.syncingFileIds.toList() else emptyList(),
|
||||
"syncedFileIds" to syncedFileIds,
|
||||
"missingRoots" to (accountId?.let {
|
||||
SyncEngine.loadMissingRoots(applicationContext, it).toList()
|
||||
} ?: emptyList()),
|
||||
"conflicts" to (if (busMatches) status.conflicts else emptyList()).map {
|
||||
mapOf(
|
||||
"accountId" to it.accountId,
|
||||
"fileId" to it.fileId,
|
||||
"remotePath" to it.remotePath,
|
||||
"relPath" to it.relPath,
|
||||
"name" to it.name,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
||||
/// buttons) - shares [ConflictResolveWorker.enqueue] with
|
||||
/// [SyncConflictReceiver], the only other caller, so there's one
|
||||
/// resolution code path regardless of whether it's triggered from a
|
||||
/// notification or from inside the app.
|
||||
private fun resolveConflict(call: MethodCall, result: MethodChannel.Result) {
|
||||
ConflictResolveWorker.enqueue(
|
||||
this,
|
||||
accountId = call.argument<String>("accountId"),
|
||||
serverUrl = call.argument<String>("serverUrl"),
|
||||
username = call.argument<String>("username"),
|
||||
authHeader = call.argument<String>("authHeader"),
|
||||
fileId = call.argument<String>("fileId"),
|
||||
remotePath = call.argument<String>("remotePath"),
|
||||
relPath = call.argument<String>("relPath"),
|
||||
resolution = call.argument<String>("resolution"),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -82,6 +247,10 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
if (shared.isNotEmpty()) {
|
||||
mainHandler.post { newShareSink?.success(shared) }
|
||||
}
|
||||
val pickRequest = extractPickRequest(intent)
|
||||
if (pickRequest != null) {
|
||||
mainHandler.post { newPickSink?.success(pickRequest) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap Uri metadata only (display name / size / mime type) via a
|
||||
@@ -180,4 +349,249 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
ContextCompat.startForegroundService(this, serviceIntent)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
/// Starts DownloadService with everything it needs to run entirely on
|
||||
/// its own (see its doc comment) - same contract shape as
|
||||
/// [startUploadService], kept in sync manually for the same reason.
|
||||
private fun startDownloadService(call: MethodCall, result: MethodChannel.Result) {
|
||||
val filesJson = call.argument<String>("files")
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
val username = call.argument<String>("username")
|
||||
val authHeader = call.argument<String>("authHeader")
|
||||
if (filesJson == null || serverUrl == null || username == null || authHeader == null) {
|
||||
result.error("bad_args", "Missing required download arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// Fire-and-forget: the service works fine even if this is denied,
|
||||
// it just won't be able to show progress/cancel in a notification.
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
notificationPermissionRequestCode,
|
||||
)
|
||||
}
|
||||
|
||||
val serviceIntent = Intent(this, DownloadService::class.java).apply {
|
||||
putExtra(DownloadService.EXTRA_FILES, filesJson)
|
||||
putExtra(DownloadService.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(DownloadService.EXTRA_USERNAME, username)
|
||||
putExtra(DownloadService.EXTRA_AUTH_HEADER, authHeader)
|
||||
}
|
||||
ContextCompat.startForegroundService(this, serviceIntent)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
/// Shared arg-parsing for `reschedule`/`syncNow` - both need the same
|
||||
/// account/credentials/folder-list shape, just enqueue differently.
|
||||
private fun syncWorkData(call: MethodCall, force: Boolean = false): androidx.work.Data? {
|
||||
val accountId = call.argument<String>("accountId")
|
||||
val serverUrl = call.argument<String>("serverUrl")
|
||||
val username = call.argument<String>("username")
|
||||
val authHeader = call.argument<String>("authHeader")
|
||||
val foldersJson = call.argument<String>("folders")
|
||||
if (accountId == null || serverUrl == null || username == null ||
|
||||
authHeader == null || foldersJson == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return workDataOf(
|
||||
SyncWorker.KEY_ACCOUNT_ID to accountId,
|
||||
SyncWorker.KEY_SERVER_URL to serverUrl,
|
||||
SyncWorker.KEY_USERNAME to username,
|
||||
SyncWorker.KEY_AUTH_HEADER to authHeader,
|
||||
SyncWorker.KEY_FOLDERS to foldersJson,
|
||||
SyncWorker.KEY_FORCE to force,
|
||||
SyncWorker.KEY_NOTIFY to (call.argument<Boolean>("notify") ?: true),
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-enqueues (or cancels, if the folder list is now empty) the
|
||||
/// periodic device-sync job - called from Dart whenever the synced-
|
||||
/// folder list, active account, or Wi-Fi-only setting changes, since a
|
||||
/// periodic WorkRequest's input Data/constraints are fixed at enqueue
|
||||
/// time and can only be changed by cancelling and re-enqueueing.
|
||||
private fun rescheduleSyncWork(call: MethodCall, result: MethodChannel.Result) {
|
||||
val accountId = call.argument<String>("accountId")
|
||||
if (accountId == null) {
|
||||
result.error("bad_args", "Missing accountId", null)
|
||||
return
|
||||
}
|
||||
val data = syncWorkData(call)
|
||||
val foldersJson = call.argument<String>("folders")
|
||||
val wifiOnly = call.argument<Boolean>("wifiOnly") ?: true
|
||||
// Null = periodic background sync is off (Files Cache set to
|
||||
// "never"/"manual") - only foreground triggers and pull-to-refresh
|
||||
// sync then. See SyncStatusController's doc comment.
|
||||
val intervalMinutes = call.argument<Int>("intervalMinutes")
|
||||
val workManager = WorkManager.getInstance(this)
|
||||
// The single shared job from before per-account scheduling.
|
||||
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
|
||||
|
||||
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0 ||
|
||||
intervalMinutes == null
|
||||
) {
|
||||
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||
.build()
|
||||
// Follows the Files Cache "refresh periodically" interval. Android
|
||||
// won't run periodic work more often than every 15 minutes, so
|
||||
// shorter intervals are clamped up (the app's own in-foreground
|
||||
// timer covers the shorter cadence while it's open).
|
||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
maxOf(intervalMinutes, 15).toLong(),
|
||||
TimeUnit.MINUTES,
|
||||
)
|
||||
.setInputData(data)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
SyncWorker.periodicNameFor(accountId),
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
request,
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
/// Cancels one account's periodic job (e.g. when it's removed).
|
||||
private fun cancelSyncWork(call: MethodCall, result: MethodChannel.Result) {
|
||||
val accountId = call.argument<String>("accountId")
|
||||
val workManager = WorkManager.getInstance(this)
|
||||
if (accountId != null) {
|
||||
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
|
||||
}
|
||||
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
/// Deletes a path's local mirror once the user turns sync off for it -
|
||||
/// see [SyncEngine.removeLocalSync]. Runs off the main thread since it
|
||||
/// walks/deletes a directory tree; `result.success` is posted back via
|
||||
/// [mainHandler] since MethodChannel results must be delivered on the
|
||||
/// platform thread.
|
||||
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
|
||||
val accountId = call.argument<String>("accountId")
|
||||
val path = call.argument<String>("path")
|
||||
if (accountId == null || path == null) {
|
||||
result.error("bad_args", "Missing required arguments", null)
|
||||
return
|
||||
}
|
||||
Thread {
|
||||
SyncEngine.removeLocalSync(applicationContext, accountId, path)
|
||||
mainHandler.post { result.success(null) }
|
||||
}.start()
|
||||
}
|
||||
|
||||
/// One-off immediate run (Settings' "Sync now"), independent of the
|
||||
/// periodic schedule.
|
||||
private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
|
||||
// `force` = an explicit "Sync now" / newly-added path: full walk,
|
||||
// visible progress, and it supersedes any in-flight run. Otherwise
|
||||
// (pull-to-refresh, foreground timer, app resume) it's a quiet
|
||||
// check that never interrupts a run already in progress; those may
|
||||
// additionally respect the Wi-Fi-only setting (`wifiOnly` present).
|
||||
val force = call.argument<Boolean>("force") ?: true
|
||||
val data = syncWorkData(call, force)
|
||||
if (data == null) {
|
||||
result.error("bad_args", "Missing required sync arguments", null)
|
||||
return
|
||||
}
|
||||
val wifiOnly = call.argument<Boolean>("wifiOnly")
|
||||
val builder = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data)
|
||||
if (wifiOnly != null) {
|
||||
builder.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
WorkManager.getInstance(this).enqueueUniqueWork(
|
||||
// Per account so switching accounts doesn't let one account's
|
||||
// run replace/keep-out another's.
|
||||
SyncWorker.oneOffNameFor(call.argument<String>("accountId")!!),
|
||||
if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
|
||||
builder.build(),
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
/// Non-null only when this Activity was launched (or re-delivered a new
|
||||
/// Intent) as another app's GET_CONTENT picker - the mimeType filter and
|
||||
/// multi-select flag the caller asked for, plus a best-effort display
|
||||
/// name for the calling app to show in the "picking mode" banner.
|
||||
private fun extractPickRequest(intent: Intent): Map<String, Any?>? {
|
||||
if (intent.action != Intent.ACTION_GET_CONTENT) return null
|
||||
val mimeType = intent.type ?: "*/*"
|
||||
val allowMultiple = intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
|
||||
val callerPackage = callingPackage
|
||||
val callerLabel = callerPackage?.let {
|
||||
try {
|
||||
val appInfo = packageManager.getApplicationInfo(it, 0)
|
||||
packageManager.getApplicationLabel(appInfo).toString()
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
it
|
||||
}
|
||||
}
|
||||
return mapOf(
|
||||
"mimeType" to mimeType,
|
||||
"allowMultiple" to allowMultiple,
|
||||
"callerLabel" to callerLabel,
|
||||
)
|
||||
}
|
||||
|
||||
/// Hands the already-downloaded local files (see PickIntentService/
|
||||
/// ServerProvider.confirmPick - this Activity never touches the
|
||||
/// Nextcloud server itself) back to the caller as content:// Uris
|
||||
/// through this app's own FileProvider, then closes the picker.
|
||||
private fun finishPick(call: MethodCall, result: MethodChannel.Result) {
|
||||
val paths = call.argument<List<String>>("paths")
|
||||
val mimeTypes = call.argument<List<String>>("mimeTypes") ?: emptyList()
|
||||
if (paths.isNullOrEmpty()) {
|
||||
result.error("no_files", "No files to return to the caller", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uris = paths.map { path ->
|
||||
FileProvider.getUriForFile(this, pickerFileProviderAuthority, File(path))
|
||||
}
|
||||
val resultIntent = Intent().apply {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
// `Intent.setData()` and `.setType()` each silently null out the
|
||||
// other field if called separately (Android framework behavior) -
|
||||
// `setDataAndType` is the only way to have both survive on the same
|
||||
// Intent, which every GET_CONTENT caller expects for `data`.
|
||||
val firstMimeType = mimeTypes.getOrNull(0) ?: "*/*"
|
||||
if (uris.size == 1) {
|
||||
resultIntent.setDataAndType(uris[0], firstMimeType)
|
||||
} else {
|
||||
val clipMimeTypes = mimeTypes.ifEmpty { listOf("*/*") }.toTypedArray()
|
||||
val clipData = ClipData("Noo picked files", clipMimeTypes, ClipData.Item(uris[0]))
|
||||
for (i in 1 until uris.size) clipData.addItem(ClipData.Item(uris[i]))
|
||||
resultIntent.clipData = clipData
|
||||
// Some callers only read `data` even for a multi-item pick, so
|
||||
// this still points at the first file as a fallback.
|
||||
resultIntent.setDataAndType(uris[0], firstMimeType)
|
||||
}
|
||||
setResult(RESULT_OK, resultIntent)
|
||||
result.success(null)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun cancelPick(result: MethodChannel.Result) {
|
||||
setResult(RESULT_CANCELED)
|
||||
result.success(null)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Shared notification-channel creation - [DownloadService],
|
||||
* [ShareUploadService], and [SyncWorker] each independently re-implemented
|
||||
* this same `if (SDK_INT >= O) { NotificationChannel(...); create }` shape
|
||||
* before this existed.
|
||||
*/
|
||||
object NooNotificationChannels {
|
||||
fun ensure(
|
||||
context: Context,
|
||||
id: String,
|
||||
name: String,
|
||||
importance: Int,
|
||||
description: String,
|
||||
// Old channel ids this one replaces. A channel's importance (and so
|
||||
// whether it makes sound) can't be changed once it's been created -
|
||||
// Android ignores later changes - so changing it means moving to a
|
||||
// new id and removing the old channel.
|
||||
legacyIds: List<String> = emptyList(),
|
||||
) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
legacyIds.forEach { manager.deleteNotificationChannel(it) }
|
||||
val channel = NotificationChannel(id, name, importance).apply {
|
||||
this.description = description
|
||||
if (importance <= NotificationManager.IMPORTANCE_LOW) {
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
}
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
@@ -16,9 +15,7 @@ import androidx.core.app.ServiceCompat
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Foreground service that prepares (copies from a content:// Uri) and
|
||||
@@ -28,19 +25,18 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
* doesn't interrupt the upload, the same way a file-manager app's own
|
||||
* upload notification survives the app being closed.
|
||||
*
|
||||
* Re-implements a plain WebDAV PUT here in Kotlin (HttpURLConnection, no
|
||||
* new HTTP dependency) rather than reusing NextcloudService/Dio from Dart,
|
||||
* since a Dart isolate doesn't keep running once the Flutter engine/
|
||||
* Activity are gone - only a real Android Service does. This does mean the
|
||||
* PUT request itself is duplicated logic (see NextcloudService.
|
||||
* uploadFileFromPath); keep both in sync if the upload semantics change.
|
||||
* PUT itself is [DavTransfer.put]; this file owns only what's specific to
|
||||
* a share-upload - materializing the source content:// Uri into a real
|
||||
* file first (WebDAV PUT needs a known Content-Length, which streaming
|
||||
* straight from a content:// Uri can't always provide) and the
|
||||
* notification/queue plumbing.
|
||||
*
|
||||
* Started via the `dev.ayushya.noo/upload_service` MethodChannel
|
||||
* (MainActivity.kt) with credentials/destination passed as Intent extras -
|
||||
* never has an Activity in the loop after that. Shows one persistent,
|
||||
* cancellable notification for the whole batch; the Cancel action re-enters
|
||||
* this same running service instance with [ACTION_CANCEL], which the
|
||||
* copy/upload loops poll.
|
||||
* cancellable notification for the whole batch; a second share arriving
|
||||
* mid-upload queues behind the first (see [TransferQueue]) instead of
|
||||
* being dropped.
|
||||
*/
|
||||
class ShareUploadService : Service() {
|
||||
companion object {
|
||||
@@ -51,20 +47,30 @@ class ShareUploadService : Service() {
|
||||
const val EXTRA_AUTH_HEADER = "authHeader"
|
||||
const val EXTRA_REMOTE_FOLDER = "remoteFolder"
|
||||
|
||||
private const val CHANNEL_ID = "share_upload"
|
||||
private const val CHANNEL_ID = "share_upload_v2"
|
||||
private const val NOTIFICATION_ID = 4201
|
||||
}
|
||||
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private var uploadThread: Thread? = null
|
||||
|
||||
private data class ShareFile(val uri: String, val name: String, val size: Long?)
|
||||
|
||||
private data class UploadBatch(
|
||||
val files: List<ShareFile>,
|
||||
val serverUrl: String,
|
||||
val username: String,
|
||||
val authHeader: String,
|
||||
val remoteFolder: String,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var cancelled = false
|
||||
private val queue = TransferQueue<UploadBatch> { runUploads(it) }
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
cancelled.set(true)
|
||||
cancelled = true
|
||||
queue.clearPending()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
@@ -80,7 +86,15 @@ class ShareUploadService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
createNotificationChannel()
|
||||
NooNotificationChannels.ensure(
|
||||
this,
|
||||
CHANNEL_ID,
|
||||
"File uploads",
|
||||
// Default (not low) importance so a finished upload actually
|
||||
// alerts; the progress updates themselves stay silent below.
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
"Progress for files shared to Noo",
|
||||
)
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
NOTIFICATION_ID,
|
||||
@@ -92,16 +106,7 @@ class ShareUploadService : Service() {
|
||||
},
|
||||
)
|
||||
|
||||
// A second share arriving mid-upload is dropped rather than queued -
|
||||
// rare in practice (sharing again before the first batch finishes),
|
||||
// not worth a real queue for.
|
||||
if (uploadThread == null) {
|
||||
val files = parseFiles(filesJson)
|
||||
uploadThread = Thread {
|
||||
runUploads(files, serverUrl, username, authHeader, remoteFolder)
|
||||
}.also { it.start() }
|
||||
}
|
||||
|
||||
queue.enqueue(UploadBatch(parseFiles(filesJson), serverUrl, username, authHeader, remoteFolder))
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
@@ -117,23 +122,22 @@ class ShareUploadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun runUploads(
|
||||
files: List<ShareFile>,
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remoteFolder: String,
|
||||
) {
|
||||
private fun runUploads(batch: UploadBatch) {
|
||||
cancelled = false
|
||||
var succeeded = 0
|
||||
var failed = 0
|
||||
val cleanServer = serverUrl.trimEnd('/')
|
||||
var cleanFolder = remoteFolder.trim()
|
||||
val cleanServer = batch.serverUrl.trimEnd('/')
|
||||
var cleanFolder = batch.remoteFolder.trim()
|
||||
if (!cleanFolder.startsWith("/")) cleanFolder = "/$cleanFolder"
|
||||
if (!cleanFolder.endsWith("/")) cleanFolder = "$cleanFolder/"
|
||||
|
||||
for ((index, file) in files.withIndex()) {
|
||||
if (cancelled.get()) break
|
||||
val label = if (files.size == 1) file.name else "${file.name} (${index + 1}/${files.size})"
|
||||
for ((index, file) in batch.files.withIndex()) {
|
||||
if (cancelled) break
|
||||
val label = if (batch.files.size == 1) {
|
||||
file.name
|
||||
} else {
|
||||
"${file.name} (${index + 1}/${batch.files.size})"
|
||||
}
|
||||
var tempFile: File? = null
|
||||
try {
|
||||
notify(buildProgressNotification("Preparing $label…", null, indeterminate = true))
|
||||
@@ -146,11 +150,16 @@ class ShareUploadService : Service() {
|
||||
),
|
||||
)
|
||||
}
|
||||
if (cancelled.get()) break
|
||||
if (cancelled) break
|
||||
|
||||
val encodedName = Uri.encode(file.name)
|
||||
val url = URL("$cleanServer/remote.php/dav/files/$username$cleanFolder$encodedName")
|
||||
val ok = uploadFile(tempFile, url, authHeader) { sent, total ->
|
||||
val url = URL("$cleanServer/remote.php/dav/files/${batch.username}$cleanFolder$encodedName")
|
||||
val ok = DavTransfer.put(
|
||||
url,
|
||||
batch.authHeader,
|
||||
tempFile,
|
||||
contentType = "application/octet-stream",
|
||||
onProgress = { sent, total ->
|
||||
notify(
|
||||
buildProgressNotification(
|
||||
"Uploading $label…",
|
||||
@@ -158,7 +167,9 @@ class ShareUploadService : Service() {
|
||||
indeterminate = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
isCancelled = { cancelled },
|
||||
)
|
||||
if (ok) succeeded++ else failed++
|
||||
} catch (e: Exception) {
|
||||
failed++
|
||||
@@ -167,18 +178,26 @@ class ShareUploadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val finalText = when {
|
||||
cancelled.get() -> "Upload cancelled"
|
||||
failed == 0 && files.size == 1 -> "Uploaded ${files.first().name}"
|
||||
failed == 0 -> "Uploaded $succeeded of ${files.size} files"
|
||||
else -> "Uploaded $succeeded of ${files.size} files - $failed failed"
|
||||
cancelled -> "Upload cancelled"
|
||||
failed == 0 && batch.files.size == 1 -> "Uploaded ${batch.files.first().name}"
|
||||
failed == 0 -> "Uploaded $succeeded of ${batch.files.size} files"
|
||||
else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed"
|
||||
}
|
||||
manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText))
|
||||
// batch.remoteFolder, not the locally-normalized cleanFolder - Dart's
|
||||
// FilesController.currentFolderPath never carries the trailing
|
||||
// slash cleanFolder adds, so publishing the original value lets the
|
||||
// Dart side compare them directly with no reformatting of its own.
|
||||
if (succeeded > 0) {
|
||||
UploadEventBus.publishCompleted(batch.remoteFolder, succeeded, failed)
|
||||
}
|
||||
manager.notify(NOTIFICATION_ID, buildFinalNotification(finalText))
|
||||
|
||||
if (queue.pendingCount == 0) {
|
||||
stopForeground(STOP_FOREGROUND_DETACH)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun progressFraction(sent: Long, total: Long?): Float? {
|
||||
if (total == null || total <= 0) return null
|
||||
@@ -193,7 +212,7 @@ class ShareUploadService : Service() {
|
||||
val buffer = ByteArray(256 * 1024)
|
||||
var sent = 0L
|
||||
var lastEmit = 0L
|
||||
while (!cancelled.get()) {
|
||||
while (!cancelled) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
@@ -209,70 +228,11 @@ class ShareUploadService : Service() {
|
||||
return target
|
||||
}
|
||||
|
||||
private fun uploadFile(
|
||||
file: File,
|
||||
url: URL,
|
||||
authHeader: String,
|
||||
onProgress: (Long, Long) -> Unit,
|
||||
): Boolean {
|
||||
val length = file.length()
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
connection.requestMethod = "PUT"
|
||||
connection.doOutput = true
|
||||
connection.setFixedLengthStreamingMode(length)
|
||||
connection.setRequestProperty("Authorization", authHeader)
|
||||
connection.setRequestProperty("OCS-APIRequest", "true")
|
||||
connection.setRequestProperty("Content-Type", "application/octet-stream")
|
||||
connection.connectTimeout = 15000
|
||||
connection.readTimeout = 30000
|
||||
|
||||
connection.outputStream.use { output ->
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(256 * 1024)
|
||||
var sent = 0L
|
||||
var lastEmit = 0L
|
||||
while (!cancelled.get()) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
sent += read
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastEmit >= 200) {
|
||||
lastEmit = now
|
||||
onProgress(sent, length)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cancelled.get()) {
|
||||
false
|
||||
} else {
|
||||
val code = connection.responseCode
|
||||
code == 201 || code == 204 || code == 200
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
private fun manager() =
|
||||
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
private fun notify(notification: Notification) {
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"File uploads",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { description = "Progress for files shared to Noo" }
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
manager().notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun buildProgressNotification(text: String, progress: Float?, indeterminate: Boolean): Notification {
|
||||
@@ -288,6 +248,7 @@ class ShareUploadService : Service() {
|
||||
.setContentTitle("Uploading to Noo")
|
||||
.setContentText(text)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
|
||||
when {
|
||||
@@ -308,7 +269,7 @@ class ShareUploadService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelled.set(true)
|
||||
cancelled = true
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
/**
|
||||
* Handles the "Keep local" / "Use server" actions on a sync-conflict
|
||||
* notification (see `SyncWorker.notifyConflicts`). A `BroadcastReceiver`
|
||||
* can't block on network itself (~10s execution budget), so this only
|
||||
* dismisses the notification and hands off to a one-shot
|
||||
* [ConflictResolveWorker] for the actual upload/download.
|
||||
*/
|
||||
class SyncConflictReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val ACTION_RESOLVE = "dev.ayushya.noo.action.RESOLVE_SYNC_CONFLICT"
|
||||
const val EXTRA_ACCOUNT_ID = "accountId"
|
||||
const val EXTRA_SERVER_URL = "serverUrl"
|
||||
const val EXTRA_USERNAME = "username"
|
||||
const val EXTRA_AUTH_HEADER = "authHeader"
|
||||
const val EXTRA_FILE_ID = "fileId"
|
||||
const val EXTRA_REMOTE_PATH = "remotePath"
|
||||
const val EXTRA_REL_PATH = "relPath"
|
||||
const val EXTRA_RESOLUTION = "resolution" // "local" | "server"
|
||||
const val EXTRA_NOTIFICATION_ID = "notificationId"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != ACTION_RESOLVE) return
|
||||
|
||||
val notificationId = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1)
|
||||
if (notificationId != -1) {
|
||||
NotificationManagerCompat.from(context).cancel(notificationId)
|
||||
}
|
||||
|
||||
ConflictResolveWorker.enqueue(
|
||||
context,
|
||||
accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID),
|
||||
serverUrl = intent.getStringExtra(EXTRA_SERVER_URL),
|
||||
username = intent.getStringExtra(EXTRA_USERNAME),
|
||||
authHeader = intent.getStringExtra(EXTRA_AUTH_HEADER),
|
||||
fileId = intent.getStringExtra(EXTRA_FILE_ID),
|
||||
remotePath = intent.getStringExtra(EXTRA_REMOTE_PATH),
|
||||
relPath = intent.getStringExtra(EXTRA_REL_PATH),
|
||||
resolution = intent.getStringExtra(EXTRA_RESOLUTION),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
import org.w3c.dom.Element
|
||||
|
||||
/**
|
||||
* The actual WebDAV walk/diff/GET/PUT engine for device sync - shared by
|
||||
* [SyncWorker] (periodic/manual full-folder sync) and
|
||||
* [ConflictResolveWorker] (single-file resolution from a notification
|
||||
* action), since both need the same PROPFIND/GET/PUT primitives and the
|
||||
* same on-disk sync-state bookkeeping.
|
||||
*
|
||||
* Deliberately plain Kotlin over `HttpURLConnection`, not
|
||||
* `NextcloudService`'s Dart/Dio code - this has to run from a
|
||||
* [androidx.work.CoroutineWorker], independent of the Flutter engine even
|
||||
* being loaded, the same reason `DownloadService.kt`/`ShareUploadService.kt`
|
||||
* re-implement GET/PUT natively instead of calling back into Dart.
|
||||
*/
|
||||
object SyncEngine {
|
||||
data class RemoteEntry(
|
||||
val path: String,
|
||||
val fileId: String,
|
||||
val etag: String,
|
||||
val lastModified: Long,
|
||||
val size: Long,
|
||||
val isFolder: Boolean,
|
||||
)
|
||||
|
||||
data class FileState(
|
||||
val relPath: String,
|
||||
val etag: String,
|
||||
val lastModified: Long,
|
||||
val size: Long,
|
||||
val localMTime: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* What we last saw for a configured sync root when a walk of it fully
|
||||
* completed cleanly: its own etag (Nextcloud propagates a change to any
|
||||
* descendant up through every ancestor folder's etag, so an unchanged
|
||||
* root etag means nothing beneath it changed on the server) and when
|
||||
* that full walk happened. Lets a background pass answer "did anything
|
||||
* change?" with one tiny Depth-0 PROPFIND per root instead of
|
||||
* re-walking the whole tree every time.
|
||||
*/
|
||||
data class RootMarker(val etag: String, val fullWalkAt: Long)
|
||||
|
||||
/**
|
||||
* The server couldn't be reached or answered with an error (anything
|
||||
* but a clean 404). Distinct from "the path is genuinely gone/empty":
|
||||
* treating a network blip as an empty listing made every previously
|
||||
* synced file look deleted server-side, and the diff then deleted the
|
||||
* local copies. Callers skip the affected path for this run instead.
|
||||
*/
|
||||
class RemoteUnavailableException(message: String, cause: Throwable? = null) :
|
||||
Exception(message, cause)
|
||||
|
||||
sealed class SyncAction {
|
||||
data class Download(val entry: RemoteEntry) : SyncAction()
|
||||
data class Upload(val relPath: String, val fileId: String) : SyncAction()
|
||||
data class Delete(val relPath: String, val fileId: String) : SyncAction()
|
||||
data class Conflict(val entry: RemoteEntry, val relPath: String) : SyncAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* Held for the whole of any run that reads-then-rewrites a sync state
|
||||
* map ([SyncWorker], [ConflictResolveWorker]). The periodic job and the
|
||||
* one-off "sync now" jobs have different WorkManager names, so
|
||||
* WorkManager itself happily runs them at once - and both load the
|
||||
* state, mutate it, then save the *whole* map back, so whichever
|
||||
* finished last silently discarded the other's updates. One process-wide
|
||||
* lock serializes them (also keeps [SyncStatusBus], which only tracks
|
||||
* one account at a time, coherent when several accounts are due).
|
||||
*/
|
||||
val syncLock = Mutex()
|
||||
|
||||
private const val TAG = "NooSync"
|
||||
private const val STATE_PREFS = "noo_sync_state"
|
||||
private val rfc1123 =
|
||||
SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
|
||||
|
||||
// Only used for PROPFIND - unlike HttpURLConnection, OkHttp doesn't
|
||||
// reject non-standard HTTP methods.
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun syncRoot(context: Context, accountId: String): File {
|
||||
val base = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
return File(base, "sync/$accountId").apply { mkdirs() }
|
||||
}
|
||||
|
||||
private fun stateKey(accountId: String) = "state_$accountId"
|
||||
private fun rootsKey(accountId: String) = "roots_$accountId"
|
||||
private fun rootsVersionKey(accountId: String) = "roots_version_$accountId"
|
||||
private fun missingKey(accountId: String) = "missing_$accountId"
|
||||
|
||||
// Bumped whenever a walk starts doing something new (e.g. mirroring
|
||||
// empty folders) so markers recorded by an older version - which would
|
||||
// make the next run skip the walk - are ignored once.
|
||||
private const val ROOTS_VERSION = 2
|
||||
|
||||
fun loadRootMarkers(context: Context, accountId: String): MutableMap<String, RootMarker> {
|
||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||
if (prefs.getInt(rootsVersionKey(accountId), 0) != ROOTS_VERSION) return mutableMapOf()
|
||||
val json = prefs.getString(rootsKey(accountId), null) ?: return mutableMapOf()
|
||||
val obj = JSONObject(json)
|
||||
val result = mutableMapOf<String, RootMarker>()
|
||||
for (path in obj.keys()) {
|
||||
val entry = obj.getJSONObject(path)
|
||||
result[path] = RootMarker(entry.getString("etag"), entry.getLong("fullWalkAt"))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun saveRootMarkers(context: Context, accountId: String, markers: Map<String, RootMarker>) {
|
||||
val obj = JSONObject()
|
||||
for ((path, m) in markers) {
|
||||
obj.put(path, JSONObject().apply {
|
||||
put("etag", m.etag)
|
||||
put("fullWalkAt", m.fullWalkAt)
|
||||
})
|
||||
}
|
||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||
prefs.edit()
|
||||
.putString(rootsKey(accountId), obj.toString())
|
||||
.putInt(rootsVersionKey(accountId), ROOTS_VERSION)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured sync roots the server reported as gone (a clean 404) - e.g.
|
||||
* a synced file or folder that was deleted on the web. The app removes
|
||||
* these from its synced list (there's nothing left to sync) and then
|
||||
* clears them via [removeLocalSync].
|
||||
*/
|
||||
fun loadMissingRoots(context: Context, accountId: String): Set<String> {
|
||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||
return prefs.getStringSet(missingKey(accountId), emptySet())?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
fun setRootMissing(context: Context, accountId: String, path: String, missing: Boolean) {
|
||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||
val current = loadMissingRoots(context, accountId).toMutableSet()
|
||||
val changed = if (missing) current.add(path) else current.remove(path)
|
||||
if (changed) prefs.edit().putStringSet(missingKey(accountId), current).apply()
|
||||
}
|
||||
|
||||
/** Creates a local directory for every remote folder, so empty folders exist on device too. */
|
||||
fun mirrorFolders(entries: List<RemoteEntry>, syncRoot: File, rootRel: String) {
|
||||
if (rootRel.isNotEmpty()) File(syncRoot, rootRel).mkdirs()
|
||||
for (e in entries) {
|
||||
if (e.isFolder) File(syncRoot, e.path.trim('/')).mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes local directories under [rootRel] that no longer exist on the
|
||||
* server ([remoteFolders], relative paths) - the counterpart to
|
||||
* [mirrorFolders]. Only *empty* directories are removed: by the time this
|
||||
* runs the files that were inside a deleted server folder have already
|
||||
* been deleted by the diff, while a directory that still holds something
|
||||
* (a file this device created that hasn't synced) is left alone. Never
|
||||
* removes the sync root itself.
|
||||
*/
|
||||
fun pruneRemovedFolders(syncRoot: File, rootRel: String, remoteFolders: Set<String>) {
|
||||
val top = if (rootRel.isEmpty()) syncRoot else File(syncRoot, rootRel)
|
||||
if (!top.isDirectory) return
|
||||
|
||||
fun prune(dir: File, rel: String) {
|
||||
dir.listFiles()?.filter { it.isDirectory }?.forEach { child ->
|
||||
prune(child, if (rel.isEmpty()) child.name else "$rel/${child.name}")
|
||||
}
|
||||
if (rel.isNotEmpty() && rel !in remoteFolders && dir.list().isNullOrEmpty()) {
|
||||
dir.delete()
|
||||
}
|
||||
}
|
||||
prune(top, rootRel)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if every file in [fileIds] is still on disk exactly as the sync
|
||||
* state last recorded it (same size and mtime) - i.e. there's nothing
|
||||
* local to upload and nothing missing to re-download. Pure local stat
|
||||
* calls, no network.
|
||||
*/
|
||||
fun localMatchesState(
|
||||
state: Map<String, FileState>,
|
||||
fileIds: Collection<String>,
|
||||
syncRoot: File,
|
||||
): Boolean = fileIds.all { id ->
|
||||
val s = state[id] ?: return@all false
|
||||
val f = File(syncRoot, s.relPath)
|
||||
f.exists() && f.length() == s.size && f.lastModified() == s.localMTime
|
||||
}
|
||||
|
||||
fun loadState(context: Context, accountId: String): MutableMap<String, FileState> {
|
||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||
val json = prefs.getString(stateKey(accountId), null) ?: return mutableMapOf()
|
||||
val obj = JSONObject(json)
|
||||
val result = mutableMapOf<String, FileState>()
|
||||
for (fileId in obj.keys()) {
|
||||
val entry = obj.getJSONObject(fileId)
|
||||
result[fileId] = FileState(
|
||||
relPath = entry.getString("relPath"),
|
||||
etag = entry.getString("etag"),
|
||||
lastModified = entry.getLong("lastModified"),
|
||||
size = entry.getLong("size"),
|
||||
localMTime = entry.getLong("localMTime"),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun saveState(context: Context, accountId: String, state: Map<String, FileState>) {
|
||||
val obj = JSONObject()
|
||||
for ((fileId, s) in state) {
|
||||
obj.put(
|
||||
fileId,
|
||||
JSONObject().apply {
|
||||
put("relPath", s.relPath)
|
||||
put("etag", s.etag)
|
||||
put("lastModified", s.lastModified)
|
||||
put("size", s.size)
|
||||
put("localMTime", s.localMTime)
|
||||
},
|
||||
)
|
||||
}
|
||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||
prefs.edit().putString(stateKey(accountId), obj.toString()).apply()
|
||||
}
|
||||
|
||||
/** Depth-1 PROPFIND of [remotePath], returning its direct children only. */
|
||||
fun propfindChildren(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remotePath: String,
|
||||
): List<RemoteEntry> = propfind(serverUrl, username, authHeader, remotePath, depth = "1", skipSelf = true)
|
||||
|
||||
/** Depth-0 PROPFIND of [remotePath] itself (e.g. to refresh its etag after a PUT/GET). */
|
||||
fun propfindSelf(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remotePath: String,
|
||||
): RemoteEntry? = propfind(serverUrl, username, authHeader, remotePath, depth = "0", skipSelf = false).firstOrNull()
|
||||
|
||||
private fun propfind(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remotePath: String,
|
||||
depth: String,
|
||||
skipSelf: Boolean,
|
||||
): List<RemoteEntry> {
|
||||
var cleanPath = remotePath.trim()
|
||||
if (!cleanPath.startsWith("/")) cleanPath = "/$cleanPath"
|
||||
if (depth == "1" && !cleanPath.endsWith("/")) cleanPath = "$cleanPath/"
|
||||
val encodedPath = cleanPath.split("/").joinToString("/") { Uri.encode(it) }
|
||||
val cleanServer = serverUrl.trimEnd('/')
|
||||
val url = "$cleanServer/remote.php/dav/files/$username$encodedPath"
|
||||
|
||||
val body = """<?xml version="1.0" encoding="utf-8" ?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified/>
|
||||
<d:getcontentlength/>
|
||||
<d:resourcetype/>
|
||||
<d:getetag/>
|
||||
<oc:fileid/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.method("PROPFIND", body.toRequestBody("application/xml".toMediaType()))
|
||||
.header("Authorization", authHeader)
|
||||
.header("Depth", depth)
|
||||
.build()
|
||||
|
||||
return try {
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
Log.d(TAG, "PROPFIND $url -> ${response.code}")
|
||||
if (response.code == 404) return emptyList() // genuinely gone
|
||||
if (!response.isSuccessful) {
|
||||
Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}")
|
||||
throw RemoteUnavailableException("PROPFIND $url -> ${response.code}")
|
||||
}
|
||||
|
||||
val doc = DocumentBuilderFactory.newInstance()
|
||||
.apply { isNamespaceAware = true }
|
||||
.newDocumentBuilder()
|
||||
.parse(response.body!!.byteStream())
|
||||
|
||||
parsePropfindResponse(doc, username, cleanPath, skipSelf)
|
||||
}
|
||||
} catch (e: RemoteUnavailableException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "PROPFIND $url threw", e)
|
||||
throw RemoteUnavailableException("PROPFIND $url threw", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePropfindResponse(
|
||||
doc: org.w3c.dom.Document,
|
||||
username: String,
|
||||
cleanPath: String,
|
||||
skipSelf: Boolean,
|
||||
): List<RemoteEntry> {
|
||||
val marker = "/remote.php/dav/files/$username"
|
||||
val selfPath = cleanPath.trimEnd('/')
|
||||
val responses = doc.getElementsByTagNameNS("DAV:", "response")
|
||||
val entries = mutableListOf<RemoteEntry>()
|
||||
for (i in 0 until responses.length) {
|
||||
val responseEl = responses.item(i) as? Element ?: continue
|
||||
val hrefRaw = responseEl.getElementsByTagNameNS("DAV:", "href")
|
||||
.item(0)?.textContent ?: continue
|
||||
val decodedHref = Uri.decode(hrefRaw)
|
||||
val idx = decodedHref.indexOf(marker)
|
||||
val hrefPath = if (idx >= 0) decodedHref.substring(idx + marker.length) else decodedHref
|
||||
val hrefPathNorm = (if (hrefPath.isEmpty()) "/" else hrefPath).trimEnd('/')
|
||||
if (skipSelf && hrefPathNorm == selfPath) continue // self entry, not a child
|
||||
|
||||
val propEl = responseEl.getElementsByTagNameNS("DAV:", "prop").item(0) as? Element
|
||||
?: continue
|
||||
val resourceTypeEl =
|
||||
propEl.getElementsByTagNameNS("DAV:", "resourcetype").item(0) as? Element
|
||||
val isFolder =
|
||||
(resourceTypeEl?.getElementsByTagNameNS("DAV:", "collection")?.length ?: 0) > 0
|
||||
val etag = propEl.getElementsByTagNameNS("DAV:", "getetag")
|
||||
.item(0)?.textContent?.trim('"') ?: ""
|
||||
val lastModStr =
|
||||
propEl.getElementsByTagNameNS("DAV:", "getlastmodified").item(0)?.textContent
|
||||
val sizeStr =
|
||||
propEl.getElementsByTagNameNS("DAV:", "getcontentlength").item(0)?.textContent
|
||||
val fileId = propEl.getElementsByTagNameNS("http://owncloud.org/ns", "fileid")
|
||||
.item(0)?.textContent ?: hrefPathNorm
|
||||
|
||||
entries.add(
|
||||
RemoteEntry(
|
||||
path = hrefPathNorm,
|
||||
fileId = fileId,
|
||||
etag = etag,
|
||||
lastModified = lastModStr?.let { runCatching { rfc1123.parse(it)?.time }.getOrNull() } ?: 0L,
|
||||
size = sizeStr?.toLongOrNull() ?: 0L,
|
||||
isFolder = isFolder,
|
||||
),
|
||||
)
|
||||
}
|
||||
Log.d(TAG, "PROPFIND $cleanPath -> ${entries.size} entries")
|
||||
return entries
|
||||
}
|
||||
|
||||
/** Recursively walks [rootPath] (Depth-1 PROPFINDs, breadth-first) into a flat manifest. */
|
||||
fun walkRemoteTree(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
rootPath: String,
|
||||
): List<RemoteEntry> {
|
||||
val result = mutableListOf<RemoteEntry>()
|
||||
val queue = ArrayDeque<String>()
|
||||
queue.add(rootPath)
|
||||
while (queue.isNotEmpty()) {
|
||||
val current = queue.removeFirst()
|
||||
val children = propfindChildren(serverUrl, username, authHeader, current)
|
||||
for (child in children) {
|
||||
result.add(child)
|
||||
if (child.isFolder) queue.add(child.path)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun downloadFile(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remotePath: String,
|
||||
destination: File,
|
||||
): Boolean {
|
||||
val url = davUrl(serverUrl, username, remotePath)
|
||||
val ok = DavTransfer.get(url, authHeader, destination)
|
||||
Log.d(
|
||||
TAG,
|
||||
if (ok) {
|
||||
"GET $url -> saved to ${destination.absolutePath} (${destination.length()} bytes)"
|
||||
} else {
|
||||
"GET $url failed"
|
||||
},
|
||||
)
|
||||
return ok
|
||||
}
|
||||
|
||||
fun uploadFile(
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
remotePath: String,
|
||||
source: File,
|
||||
): Boolean {
|
||||
val url = davUrl(serverUrl, username, remotePath)
|
||||
return DavTransfer.put(url, authHeader, source)
|
||||
}
|
||||
|
||||
private fun davUrl(serverUrl: String, username: String, remotePath: String): URL {
|
||||
val encodedPath = remotePath.split("/").joinToString("/") { Uri.encode(it) }
|
||||
return URL("${serverUrl.trimEnd('/')}/remote.php/dav/files/$username$encodedPath")
|
||||
}
|
||||
|
||||
/**
|
||||
* Diffs one synced folder's remote manifest against the persisted
|
||||
* state, returning what to do for each item - callers own actually
|
||||
* doing the GET/PUT/delete and updating state afterward, so this stays
|
||||
* pure/testable-in-principle. Also returns entries no longer present
|
||||
* remotely (deleted server-side, relative to the [priorFolderRelPaths]
|
||||
* this folder previously produced) as [SyncAction.Delete].
|
||||
*/
|
||||
fun diffFolder(
|
||||
entries: List<RemoteEntry>,
|
||||
state: Map<String, FileState>,
|
||||
priorFolderFileIds: Set<String>,
|
||||
syncRoot: File,
|
||||
): List<SyncAction> {
|
||||
val actions = mutableListOf<SyncAction>()
|
||||
val seenFileIds = mutableSetOf<String>()
|
||||
|
||||
for (entry in entries) {
|
||||
if (entry.isFolder) continue
|
||||
seenFileIds.add(entry.fileId)
|
||||
val relPath = entry.path.removePrefix("/")
|
||||
val localFile = File(syncRoot, relPath)
|
||||
val prior = state[entry.fileId]
|
||||
|
||||
if (prior == null) {
|
||||
// First time this folder's been walked with sync enabled -
|
||||
// nothing locally recorded yet to protect, so just pull it.
|
||||
actions.add(SyncAction.Download(entry))
|
||||
continue
|
||||
}
|
||||
|
||||
val serverChanged = entry.etag != prior.etag
|
||||
val localExists = localFile.exists()
|
||||
val localChanged = localExists &&
|
||||
(localFile.lastModified() != prior.localMTime || localFile.length() != prior.size)
|
||||
|
||||
when {
|
||||
!localExists -> {
|
||||
// State says this file was mirrored but the local copy
|
||||
// is gone (stale state left over from an earlier sync of
|
||||
// this path, or the file was removed outside the app).
|
||||
// It's inside a path the user asked to sync, so pull it
|
||||
// back down. This used to be treated as "the user
|
||||
// deleted it, respect that" and emitted a Delete - which
|
||||
// dropped the state, skipped the download, and reported
|
||||
// phantom "N removed" for files that were never on disk.
|
||||
actions.add(SyncAction.Download(entry))
|
||||
}
|
||||
serverChanged && localChanged -> actions.add(SyncAction.Conflict(entry, relPath))
|
||||
serverChanged -> actions.add(SyncAction.Download(entry))
|
||||
localChanged -> actions.add(SyncAction.Upload(relPath, entry.fileId))
|
||||
else -> {} // unchanged, nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
// Anything this folder had state for last time but that didn't show
|
||||
// up in this walk at all was deleted server-side.
|
||||
for (fileId in priorFolderFileIds) {
|
||||
if (fileId !in seenFileIds) {
|
||||
val prior = state[fileId] ?: continue
|
||||
actions.add(SyncAction.Delete(prior.relPath, fileId))
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"diffFolder: ${entries.size} entries -> " +
|
||||
"${actions.count { it is SyncAction.Download }} downloads, " +
|
||||
"${actions.count { it is SyncAction.Upload }} uploads, " +
|
||||
"${actions.count { it is SyncAction.Delete }} deletes, " +
|
||||
"${actions.count { it is SyncAction.Conflict }} conflicts",
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes [path]'s local mirror (a single file, or a whole folder's
|
||||
* worth of files) and removes its entries from the persisted sync-state
|
||||
* map, called when the user turns sync off for that path - without
|
||||
* clearing the state too, a later re-add would see the (now missing)
|
||||
* local file as "deleted, server unchanged" and skip re-downloading it
|
||||
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
|
||||
* of pulling it back down.
|
||||
*/
|
||||
fun removeLocalSync(context: Context, accountId: String, path: String) {
|
||||
val root = syncRoot(context, accountId)
|
||||
val state = loadState(context, accountId).toMutableMap()
|
||||
|
||||
var cleanPath = path.trim()
|
||||
if (cleanPath.startsWith("/")) cleanPath = cleanPath.substring(1)
|
||||
cleanPath = cleanPath.trimEnd('/')
|
||||
val prefix = if (cleanPath.isEmpty()) "" else "$cleanPath/"
|
||||
|
||||
val toRemove = state.filterValues { s ->
|
||||
s.relPath == cleanPath || (prefix.isNotEmpty() && s.relPath.startsWith(prefix))
|
||||
}.keys
|
||||
for (fileId in toRemove) state.remove(fileId)
|
||||
saveState(context, accountId, state)
|
||||
|
||||
setRootMissing(context, accountId, "/" + cleanPath, false)
|
||||
setRootMissing(context, accountId, cleanPath, false)
|
||||
|
||||
// A root marker for this path (or an ancestor/descendant that also
|
||||
// covers some of what was just deleted) would make the next
|
||||
// background pass think nothing changed and skip re-downloading.
|
||||
val removedPath = "/" + cleanPath
|
||||
val markers = loadRootMarkers(context, accountId)
|
||||
val staleKeys = markers.keys.filter { key ->
|
||||
val k = key.trimEnd('/')
|
||||
k.isEmpty() || removedPath == "/" || k == removedPath ||
|
||||
k.startsWith("$removedPath/") || removedPath.startsWith("$k/")
|
||||
}
|
||||
if (staleKeys.isNotEmpty()) {
|
||||
staleKeys.forEach { markers.remove(it) }
|
||||
saveRootMarkers(context, accountId, markers)
|
||||
}
|
||||
|
||||
val target = if (cleanPath.isEmpty()) root else File(root, cleanPath)
|
||||
if (target.exists()) target.deleteRecursively()
|
||||
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
|
||||
}
|
||||
|
||||
fun folderIsSyncedUnder(itemPath: String, syncedFolders: List<String>): String? {
|
||||
val normalizedItem = itemPath.trimEnd('/')
|
||||
for (folder in syncedFolders) {
|
||||
val normalizedFolder = folder.trimEnd('/')
|
||||
if (normalizedItem == normalizedFolder || normalizedItem.startsWith("$normalizedFolder/")) {
|
||||
return folder
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
/**
|
||||
* In-memory, in-process pub/sub for live device-sync status -
|
||||
* [SyncWorker]/[ConflictResolveWorker] publish into this, `MainActivity`'s
|
||||
* `dev.ayushya.noo/sync_service/status` EventChannel forwards it to Dart.
|
||||
* Plain in-memory state is enough (no IPC/persistence needed) since the
|
||||
* workers and the Activity always run in the same process; contrast with
|
||||
* [SyncEngine]'s on-disk per-account state map, which *is* durable and is
|
||||
* what actually answers "is this file synced" (this bus only ever tracks
|
||||
* the transient "syncing right now" / "unresolved conflict" parts of that
|
||||
* picture).
|
||||
*/
|
||||
object SyncStatusBus {
|
||||
data class Conflict(
|
||||
val accountId: String,
|
||||
val fileId: String,
|
||||
val remotePath: String,
|
||||
val relPath: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class Status(
|
||||
val accountId: String?,
|
||||
val syncing: Boolean,
|
||||
val syncingFileIds: Set<String>,
|
||||
val conflicts: List<Conflict>,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var current = Status(accountId = null, syncing = false, syncingFileIds = emptySet(), conflicts = emptyList())
|
||||
private val listeners = mutableListOf<(Status) -> Unit>()
|
||||
|
||||
@Synchronized
|
||||
fun subscribe(listener: (Status) -> Unit) {
|
||||
listeners.add(listener)
|
||||
listener(current)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun unsubscribe(listener: (Status) -> Unit) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
fun snapshot(): Status = current
|
||||
|
||||
@Synchronized
|
||||
fun setSyncing(accountId: String, syncing: Boolean) {
|
||||
current = current.copy(accountId = accountId, syncing = syncing)
|
||||
if (!syncing) current = current.copy(syncingFileIds = emptySet())
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun markFileSyncing(accountId: String, fileId: String, syncing: Boolean) {
|
||||
val ids = current.syncingFileIds.toMutableSet()
|
||||
if (syncing) ids.add(fileId) else ids.remove(fileId)
|
||||
current = current.copy(accountId = accountId, syncingFileIds = ids)
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addConflicts(accountId: String, newConflicts: List<Conflict>) {
|
||||
if (newConflicts.isEmpty()) return
|
||||
val existingIds = current.conflicts.map { it.fileId }.toSet()
|
||||
val merged = current.conflicts + newConflicts.filter { it.fileId !in existingIds }
|
||||
current = current.copy(accountId = accountId, conflicts = merged)
|
||||
publish()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun removeConflict(accountId: String, fileId: String) {
|
||||
current = current.copy(accountId = accountId, conflicts = current.conflicts.filter { it.fileId != fileId })
|
||||
publish()
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
listeners.toList().forEach { it(current) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The periodic (and manual "Sync now") device-sync job. Runs entirely
|
||||
* independent of the Flutter engine (see [SyncEngine]'s doc comment) -
|
||||
* enqueued/re-enqueued from `MainActivity.kt`'s
|
||||
* `dev.ayushya.noo/sync_service` MethodChannel whenever the synced-folder
|
||||
* list, account, or network setting changes, since a periodic
|
||||
* `WorkRequest`'s input `Data` is fixed at enqueue time.
|
||||
*/
|
||||
class SyncWorker(appContext: Context, params: WorkerParameters) :
|
||||
CoroutineWorker(appContext, params) {
|
||||
|
||||
companion object {
|
||||
const val KEY_ACCOUNT_ID = "accountId"
|
||||
const val KEY_SERVER_URL = "serverUrl"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_AUTH_HEADER = "authHeader"
|
||||
const val KEY_FOLDERS = "folders" // JSON array of remote file/folder paths
|
||||
|
||||
// True for a user-initiated "Sync now": always does a full walk of
|
||||
// every root (never trusts the root-etag shortcut) and shows the
|
||||
// "Checking for changes..." notification immediately. False for
|
||||
// periodic/automatic runs, which stay silent unless something
|
||||
// actually transfers.
|
||||
const val KEY_FORCE = "force"
|
||||
|
||||
// Whether automatic (non-force) runs may post progress/summary
|
||||
// notifications - the "Background sync notifications" setting.
|
||||
// Conflicts are always notified (they need a decision), and a
|
||||
// user-initiated run (force) always shows progress.
|
||||
const val KEY_NOTIFY = "notify"
|
||||
|
||||
// Even when a root's etag hasn't changed, walk it in full at least
|
||||
// this often: etag propagation isn't reliable everywhere (external
|
||||
// storage mounts in particular), so this bounds how stale a mirror
|
||||
// can get if the cheap check is ever wrong.
|
||||
private const val FULL_WALK_MAX_AGE_MS = 6L * 60 * 60 * 1000
|
||||
|
||||
// Pre-multi-account name of the single shared periodic job - only
|
||||
// kept so it can be cancelled when migrating to per-account jobs.
|
||||
const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic"
|
||||
|
||||
// Each account with sync enabled gets its own periodic job (with
|
||||
// that account's own credentials/paths/interval baked in), so
|
||||
// background sync works for every enabled account, not just
|
||||
// whichever one was active last.
|
||||
fun periodicNameFor(accountId: String) = "${UNIQUE_PERIODIC_NAME}_$accountId"
|
||||
fun oneOffNameFor(accountId: String) = "noo_sync_now_$accountId"
|
||||
|
||||
// Low importance = no sound/vibration. Importance can't be changed
|
||||
// on an existing channel, hence the new id (the old "device_sync"
|
||||
// channel, which made noise, is deleted when this one is created).
|
||||
private const val CHANNEL_ID = "device_sync_v2"
|
||||
private const val LEGACY_CHANNEL_ID = "device_sync"
|
||||
|
||||
// Notification ids are per account so two accounts syncing never
|
||||
// overwrite each other's progress/summary/conflict notifications.
|
||||
// Progress and the final summary share one id per account - the
|
||||
// summary's notify() call naturally replaces the ongoing progress
|
||||
// notification in place (no flicker of two separate notifications),
|
||||
// and if there was nothing to actually sync, doWork explicitly
|
||||
// cancels it since no summary gets posted to replace it.
|
||||
private const val SUMMARY_NOTIFICATION_ID_BASE = 100_000
|
||||
private const val CONFLICT_NOTIFICATION_ID_BASE = 200_000
|
||||
fun summaryNotificationId(accountId: String) =
|
||||
SUMMARY_NOTIFICATION_ID_BASE + (accountId.hashCode() and 0xFFFF)
|
||||
fun conflictNotificationId(accountId: String, relPath: String) =
|
||||
CONFLICT_NOTIFICATION_ID_BASE + ((accountId + "|" + relPath).hashCode() and 0xFFFF)
|
||||
private const val TAG = "NooSync"
|
||||
}
|
||||
|
||||
// totalTransfers is only known once at least one path's been diffed -
|
||||
// grows across the run as each configured path is diffed in turn.
|
||||
private var totalTransfers = 0
|
||||
private var completedTransfers = 0
|
||||
private var progressShown = false
|
||||
|
||||
// Set at the top of performSync - the notify* helpers below need the
|
||||
// account (for its notification ids / title) and whether this run may
|
||||
// show progress at all.
|
||||
private var notifyAccountId = ""
|
||||
private var notifyUsername = ""
|
||||
private var showProgressNotifications = true
|
||||
|
||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||
// One sync at a time, app-wide - see SyncEngine.syncLock. A run that
|
||||
// has to wait just starts once the current one finishes.
|
||||
SyncEngine.syncLock.withLock { performSync() }
|
||||
}
|
||||
|
||||
private suspend fun performSync(): Result = withContext(Dispatchers.IO) {
|
||||
val accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure()
|
||||
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: return@withContext Result.failure()
|
||||
val username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure()
|
||||
val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure()
|
||||
val foldersJson = inputData.getString(KEY_FOLDERS) ?: "[]"
|
||||
val force = inputData.getBoolean(KEY_FORCE, false)
|
||||
notifyAccountId = accountId
|
||||
notifyUsername = username
|
||||
showProgressNotifications = force || inputData.getBoolean(KEY_NOTIFY, true)
|
||||
val folders = (0 until JSONArray(foldersJson).length()).map { JSONArray(foldersJson).getString(it) }
|
||||
Log.d(TAG, "doWork: account=$accountId server=$serverUrl user=$username folders=$folders")
|
||||
if (folders.isEmpty()) return@withContext Result.success()
|
||||
|
||||
val syncRoot = SyncEngine.syncRoot(applicationContext, accountId)
|
||||
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
|
||||
val markers = SyncEngine.loadRootMarkers(applicationContext, accountId)
|
||||
|
||||
var downloaded = 0
|
||||
var uploaded = 0
|
||||
var deleted = 0
|
||||
val conflicts = mutableListOf<Pair<SyncEngine.RemoteEntry, String>>()
|
||||
|
||||
SyncStatusBus.setSyncing(accountId, true)
|
||||
// For a user-initiated run, shown immediately rather than lazily on
|
||||
// the first transfer - the PROPFIND walk/diff below (per configured
|
||||
// path, potentially recursive) can itself take real time, and the
|
||||
// user just asked for a sync so they expect to see it happening.
|
||||
// Automatic runs stay silent until something actually transfers.
|
||||
if (force) notifyProgress("Checking for changes…")
|
||||
try {
|
||||
for (path in folders) {
|
||||
try {
|
||||
// A configured path can be a file or a folder now - check
|
||||
// which before deciding whether to walk it recursively or
|
||||
// just diff the single item.
|
||||
val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path)
|
||||
val pathPrefix = path.trimEnd('/') + "/"
|
||||
val priorFileIdsForPath = state.filterValues {
|
||||
it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/'))
|
||||
}.keys
|
||||
|
||||
// Cheap "did anything change?" check: an unchanged root etag
|
||||
// means nothing beneath it changed on the server, and if
|
||||
// every local file is also still exactly as recorded there's
|
||||
// nothing to upload or re-download either - so skip the walk
|
||||
// (one Depth-0 PROPFIND instead of one per subfolder).
|
||||
val marker = markers[path]
|
||||
if (!force && self != null && self.isFolder && marker != null &&
|
||||
marker.etag == self.etag &&
|
||||
System.currentTimeMillis() - marker.fullWalkAt < FULL_WALK_MAX_AGE_MS &&
|
||||
priorFileIdsForPath.isNotEmpty() &&
|
||||
SyncEngine.localMatchesState(state, priorFileIdsForPath, syncRoot)
|
||||
) {
|
||||
Log.d(TAG, "skip $path: server etag unchanged, local intact")
|
||||
continue
|
||||
}
|
||||
|
||||
// The server says this synced path no longer exists (a
|
||||
// clean 404, not a network error): flag it so the app drops
|
||||
// it from the synced list instead of leaving a dead entry.
|
||||
SyncEngine.setRootMissing(applicationContext, accountId, path, self == null)
|
||||
|
||||
val entries = when {
|
||||
self == null -> emptyList()
|
||||
self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path)
|
||||
else -> listOf(self)
|
||||
}
|
||||
var pathClean = true
|
||||
|
||||
val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot)
|
||||
totalTransfers += actions.count {
|
||||
it is SyncEngine.SyncAction.Download || it is SyncEngine.SyncAction.Upload
|
||||
}
|
||||
for (action in actions) {
|
||||
when (action) {
|
||||
is SyncEngine.SyncAction.Download -> {
|
||||
val relPath = action.entry.path.removePrefix("/")
|
||||
notifyProgress(relPath.substringAfterLast('/'))
|
||||
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true)
|
||||
val dest = File(syncRoot, relPath)
|
||||
if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) {
|
||||
state[action.entry.fileId] = SyncEngine.FileState(
|
||||
relPath = relPath,
|
||||
etag = action.entry.etag,
|
||||
lastModified = action.entry.lastModified,
|
||||
size = action.entry.size,
|
||||
localMTime = dest.lastModified(),
|
||||
)
|
||||
downloaded++
|
||||
} else {
|
||||
pathClean = false
|
||||
}
|
||||
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false)
|
||||
completedTransfers++
|
||||
}
|
||||
is SyncEngine.SyncAction.Upload -> {
|
||||
notifyProgress(action.relPath.substringAfterLast('/'))
|
||||
SyncStatusBus.markFileSyncing(accountId, action.fileId, true)
|
||||
val localFile = File(syncRoot, action.relPath)
|
||||
val remotePath = "/${action.relPath}"
|
||||
if (localFile.exists() &&
|
||||
SyncEngine.uploadFile(serverUrl, username, authHeader, remotePath, localFile)
|
||||
) {
|
||||
val prior = state[action.fileId]
|
||||
state[action.fileId] = SyncEngine.FileState(
|
||||
relPath = action.relPath,
|
||||
etag = prior?.etag ?: "",
|
||||
lastModified = localFile.lastModified(),
|
||||
size = localFile.length(),
|
||||
localMTime = localFile.lastModified(),
|
||||
)
|
||||
uploaded++
|
||||
} else {
|
||||
pathClean = false
|
||||
}
|
||||
SyncStatusBus.markFileSyncing(accountId, action.fileId, false)
|
||||
completedTransfers++
|
||||
}
|
||||
is SyncEngine.SyncAction.Delete -> {
|
||||
// Only counts toward the "removed" summary if a
|
||||
// local copy actually existed and was deleted -
|
||||
// dropping stale state for a file that was never
|
||||
// on disk isn't a removal the user should hear
|
||||
// about.
|
||||
val localFile = File(syncRoot, action.relPath)
|
||||
if (localFile.exists() && localFile.delete()) deleted++
|
||||
state.remove(action.fileId)
|
||||
}
|
||||
is SyncEngine.SyncAction.Conflict -> {
|
||||
conflicts.add(action.entry to action.relPath)
|
||||
pathClean = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the folder structure itself: empty folders
|
||||
// created on the server appear on device, and folders
|
||||
// deleted on the server (whose files the diff above has
|
||||
// already removed) don't linger as empty directories.
|
||||
if (self == null || self.isFolder) {
|
||||
val rootRel = path.trim('/')
|
||||
if (self != null) SyncEngine.mirrorFolders(entries, syncRoot, rootRel)
|
||||
val remoteFolders = entries.filter { it.isFolder }
|
||||
.map { it.path.trim('/') }
|
||||
.toMutableSet()
|
||||
if (self != null) remoteFolders.add(rootRel)
|
||||
SyncEngine.pruneRemovedFolders(syncRoot, rootRel, remoteFolders)
|
||||
}
|
||||
|
||||
// Only remember this root's etag once the whole path synced
|
||||
// without a hitch - anything left over (a failed transfer, an
|
||||
// unresolved conflict) has to be retried by a real walk next
|
||||
// time, not skipped because the etag "hasn't changed".
|
||||
if (self != null && self.isFolder && pathClean) {
|
||||
markers[path] = SyncEngine.RootMarker(self.etag, System.currentTimeMillis())
|
||||
} else {
|
||||
markers.remove(path)
|
||||
}
|
||||
} catch (e: SyncEngine.RemoteUnavailableException) {
|
||||
// Couldn't reach the server for this path - leave its state
|
||||
// and marker untouched and try again next run. Crucially,
|
||||
// never diff against an empty listing here: that would look
|
||||
// like the whole folder was deleted server-side.
|
||||
Log.w(TAG, "skipping $path this run: ${e.message}")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
markers.keys.retainAll(folders.toSet())
|
||||
SyncEngine.saveRootMarkers(applicationContext, accountId, markers)
|
||||
// State must be saved *before* announcing that syncing has
|
||||
// stopped - `syncStatusMap` (MainActivity.kt) recomputes
|
||||
// `syncedFileIds` fresh from `SyncEngine.loadState` every time
|
||||
// this bus publishes, so publishing first would hand Dart a
|
||||
// snapshot that's still missing every file this run just
|
||||
// downloaded, and nothing would ever correct it afterward
|
||||
// (no further bus event fires post-save) - newly-synced files
|
||||
// would never show their "synced" badge until the next app
|
||||
// restart forced a fresh `getStatus()` read.
|
||||
SyncEngine.saveState(applicationContext, accountId, state)
|
||||
SyncStatusBus.setSyncing(accountId, false)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"doWork done: downloaded=$downloaded uploaded=$uploaded deleted=$deleted conflicts=${conflicts.size}",
|
||||
)
|
||||
|
||||
if ((downloaded > 0 || uploaded > 0 || deleted > 0) && showProgressNotifications) {
|
||||
// Replaces the ongoing progress notification in place (same
|
||||
// ID) with a final, dismissible summary.
|
||||
notifySummary(downloaded, uploaded, deleted)
|
||||
} else if (progressShown) {
|
||||
// Nothing actually transferred (e.g. every diffed action was a
|
||||
// Delete/Conflict, or the progress notification was shown for a
|
||||
// run that ended up empty) - nothing to replace it with, so
|
||||
// just clear it rather than leaving a stale "Syncing…" behind.
|
||||
manager().cancel(summaryNotificationId(accountId))
|
||||
}
|
||||
if (conflicts.isNotEmpty()) {
|
||||
SyncStatusBus.addConflicts(
|
||||
accountId,
|
||||
conflicts.map { (entry, relPath) ->
|
||||
SyncStatusBus.Conflict(
|
||||
accountId = accountId,
|
||||
fileId = entry.fileId,
|
||||
remotePath = entry.path,
|
||||
relPath = relPath,
|
||||
name = relPath.substringAfterLast('/'),
|
||||
)
|
||||
},
|
||||
)
|
||||
notifyConflicts(accountId, serverUrl, username, authHeader, conflicts)
|
||||
}
|
||||
|
||||
Result.success()
|
||||
}
|
||||
|
||||
/// Called once immediately at the start of every run that actually has
|
||||
/// folders configured (covers the PROPFIND walk/diff phase, which can
|
||||
/// itself take real time), then again per file as transfers happen.
|
||||
/// `totalTransfers` grows as each configured path is diffed (paths are
|
||||
/// diffed one at a time), so early on this shows an indeterminate bar;
|
||||
/// it becomes determinate once the true total for this run is known. A
|
||||
/// run that turns out to have nothing to transfer cancels this
|
||||
/// notification at the end instead of leaving it stuck (see the
|
||||
/// `progressShown` check in `doWork`).
|
||||
private fun notifyProgress(fileName: String) {
|
||||
if (!showProgressNotifications) return
|
||||
progressShown = true
|
||||
createChannel()
|
||||
val text = if (totalTransfers > 1) {
|
||||
"$fileName (${completedTransfers + 1}/$totalTransfers)"
|
||||
} else {
|
||||
fileName
|
||||
}
|
||||
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("Syncing to device · $notifyUsername")
|
||||
.setContentText(text)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setOngoing(true)
|
||||
if (totalTransfers > 0) {
|
||||
builder.setProgress(totalTransfers, completedTransfers, false)
|
||||
} else {
|
||||
builder.setProgress(0, 0, true)
|
||||
}
|
||||
manager().notify(summaryNotificationId(notifyAccountId), builder.build())
|
||||
}
|
||||
|
||||
private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) {
|
||||
createChannel()
|
||||
val parts = mutableListOf<String>()
|
||||
if (downloaded > 0) parts.add("$downloaded updated")
|
||||
if (uploaded > 0) parts.add("$uploaded uploaded")
|
||||
if (deleted > 0) parts.add("$deleted removed")
|
||||
val text = parts.joinToString(", ")
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setContentTitle("Noo sync · $notifyUsername")
|
||||
.setContentText(text)
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
manager().notify(summaryNotificationId(notifyAccountId), notification)
|
||||
}
|
||||
|
||||
private fun notifyConflicts(
|
||||
accountId: String,
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
conflicts: List<Pair<SyncEngine.RemoteEntry, String>>,
|
||||
) {
|
||||
createChannel()
|
||||
for ((index, conflict) in conflicts.withIndex()) {
|
||||
val (entry, relPath) = conflict
|
||||
val fileName = relPath.substringAfterLast('/')
|
||||
val notificationId = conflictNotificationId(accountId, relPath)
|
||||
|
||||
val useLocalIntent = conflictActionIntent(
|
||||
accountId,
|
||||
serverUrl,
|
||||
username,
|
||||
authHeader,
|
||||
entry.fileId,
|
||||
entry.path,
|
||||
relPath,
|
||||
"local",
|
||||
notificationId,
|
||||
)
|
||||
val useServerIntent = conflictActionIntent(
|
||||
accountId,
|
||||
serverUrl,
|
||||
username,
|
||||
authHeader,
|
||||
entry.fileId,
|
||||
entry.path,
|
||||
relPath,
|
||||
"server",
|
||||
notificationId,
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle("Sync conflict: $fileName")
|
||||
.setContentText("$username - changed both on this device and on the server.")
|
||||
.setAutoCancel(true)
|
||||
.setSilent(true)
|
||||
.addAction(0, "Keep local", useLocalIntent)
|
||||
.addAction(0, "Use server", useServerIntent)
|
||||
.build()
|
||||
manager().notify(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun conflictActionIntent(
|
||||
accountId: String,
|
||||
serverUrl: String,
|
||||
username: String,
|
||||
authHeader: String,
|
||||
fileId: String,
|
||||
remotePath: String,
|
||||
relPath: String,
|
||||
resolution: String,
|
||||
notificationId: Int,
|
||||
): PendingIntent {
|
||||
val intent = Intent(applicationContext, SyncConflictReceiver::class.java).apply {
|
||||
action = SyncConflictReceiver.ACTION_RESOLVE
|
||||
putExtra(SyncConflictReceiver.EXTRA_ACCOUNT_ID, accountId)
|
||||
putExtra(SyncConflictReceiver.EXTRA_SERVER_URL, serverUrl)
|
||||
putExtra(SyncConflictReceiver.EXTRA_USERNAME, username)
|
||||
putExtra(SyncConflictReceiver.EXTRA_AUTH_HEADER, authHeader)
|
||||
putExtra(SyncConflictReceiver.EXTRA_FILE_ID, fileId)
|
||||
putExtra(SyncConflictReceiver.EXTRA_REMOTE_PATH, remotePath)
|
||||
putExtra(SyncConflictReceiver.EXTRA_REL_PATH, relPath)
|
||||
putExtra(SyncConflictReceiver.EXTRA_RESOLUTION, resolution)
|
||||
putExtra(SyncConflictReceiver.EXTRA_NOTIFICATION_ID, notificationId)
|
||||
}
|
||||
// Request code must be unique per (file, resolution) pair, else the
|
||||
// two actions' PendingIntents collide and only one survives.
|
||||
val requestCode = (accountId + relPath + resolution).hashCode()
|
||||
return PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun manager() =
|
||||
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
private fun createChannel() {
|
||||
NooNotificationChannels.ensure(
|
||||
applicationContext,
|
||||
CHANNEL_ID,
|
||||
"Device sync",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
"Updates and conflicts for folders synced to this device",
|
||||
legacyIds = listOf(LEGACY_CHANNEL_ID),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
|
||||
/**
|
||||
* A tiny sequential work queue for [DownloadService]/[ShareUploadService]:
|
||||
* each enqueued batch runs to completion before the next starts, instead
|
||||
* of a second batch arriving mid-transfer being silently dropped - the
|
||||
* previous behavior both services independently accepted as a known
|
||||
* limitation (see their old doc comments). One daemon worker thread,
|
||||
* started lazily on first use and parked on the queue for the life of the
|
||||
* process.
|
||||
*/
|
||||
class TransferQueue<T>(private val process: (T) -> Unit) {
|
||||
private val queue = LinkedBlockingQueue<T>()
|
||||
|
||||
@Volatile
|
||||
private var started = false
|
||||
|
||||
/** How many batches (including whatever's currently running) are queued. */
|
||||
val pendingCount: Int
|
||||
get() = queue.size
|
||||
|
||||
@Synchronized
|
||||
fun enqueue(batch: T) {
|
||||
queue.put(batch)
|
||||
if (!started) {
|
||||
started = true
|
||||
Thread {
|
||||
while (true) {
|
||||
process(queue.take())
|
||||
}
|
||||
}.apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops every batch that hasn't started running yet (not the current one). */
|
||||
fun clearPending() {
|
||||
queue.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.ayushya.noo
|
||||
|
||||
/**
|
||||
* In-memory, in-process pub/sub for "an upload batch just finished" -
|
||||
* [ShareUploadService] publishes into this, `MainActivity`'s
|
||||
* `dev.ayushya.noo/upload_service/status` EventChannel forwards it to Dart
|
||||
* so `FilesController` can refresh the destination folder if it's the one
|
||||
* currently on screen, without a manual pull-to-refresh. See
|
||||
* [SyncStatusBus]'s identical shape/rationale - the service and the
|
||||
* Activity always run in the same process, so plain in-memory pub/sub is
|
||||
* enough. Unlike that bus, this only ever carries discrete "just finished"
|
||||
* events, not ongoing state, so there's nothing to replay to a listener
|
||||
* that subscribes late.
|
||||
*/
|
||||
object UploadEventBus {
|
||||
data class Completed(
|
||||
val remoteFolder: String,
|
||||
val succeeded: Int,
|
||||
val failed: Int,
|
||||
)
|
||||
|
||||
private val listeners = mutableListOf<(Completed) -> Unit>()
|
||||
|
||||
@Synchronized
|
||||
fun subscribe(listener: (Completed) -> Unit) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun unsubscribe(listener: (Completed) -> Unit) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun publishCompleted(remoteFolder: String, succeeded: Int, failed: Int) {
|
||||
val event = Completed(remoteFolder, succeeded, failed)
|
||||
listeners.toList().forEach { it(event) }
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,14 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<monochrome>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_monochrome"
|
||||
android:inset="16%" />
|
||||
</monochrome>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Exposes only the app's own "picker" cache subfolder as content:// Uris -
|
||||
used to hand a file back to another app that launched Noo as a
|
||||
GET_CONTENT picker (see PickIntentService/MainActivity.kt). -->
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="picker" path="picker/" />
|
||||
</paths>
|
||||
|
Before Width: | Height: | Size: 263 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1 @@
|
||||
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
|
||||
@@ -1,23 +1,115 @@
|
||||
import 'dart:async';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'models/app_tab.dart';
|
||||
import 'providers/server_provider.dart';
|
||||
import 'models/pick_request.dart';
|
||||
import 'providers/connectivity_controller.dart';
|
||||
import 'providers/favorites_controller.dart';
|
||||
import 'providers/files_controller.dart';
|
||||
import 'providers/item_operations.dart';
|
||||
import 'providers/offline_controller.dart';
|
||||
import 'providers/photos_controller.dart';
|
||||
import 'providers/pick_controller.dart';
|
||||
import 'providers/recent_controller.dart';
|
||||
import 'providers/session_controller.dart';
|
||||
import 'providers/settings_controller.dart';
|
||||
import 'providers/shares_controller.dart';
|
||||
import 'providers/sync_status_controller.dart';
|
||||
import 'providers/trash_controller.dart';
|
||||
import 'services/pick_intent_service.dart';
|
||||
import 'services/share_intent_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/design_tokens.dart';
|
||||
import 'views/lock_screen_view.dart';
|
||||
import 'views/login_view.dart';
|
||||
import 'views/search_view.dart';
|
||||
import 'views/share_upload_view.dart';
|
||||
import 'widgets/app_drawer.dart';
|
||||
import 'widgets/app_tab_view_builder.dart';
|
||||
import 'widgets/floating_bottom_bar.dart';
|
||||
import 'widgets/app_top_bar.dart';
|
||||
import 'widgets/bottom_nav_bar.dart';
|
||||
import 'widgets/create_menu.dart';
|
||||
import 'widgets/noo/core/noo_avatar.dart';
|
||||
import 'widgets/noo/core/noo_button.dart';
|
||||
import 'widgets/noo/core/noo_fab.dart';
|
||||
import 'widgets/noo/nav/noo_nav_style.dart';
|
||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||
import 'widgets/noo/noo_layout.dart';
|
||||
import 'widgets/noo/overlays/noo_dialog.dart';
|
||||
import 'widgets/shell/shell_common.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
|
||||
GoogleFonts.config.allowRuntimeFetching = false;
|
||||
runApp(
|
||||
MultiProvider(
|
||||
// Split out of the former single `ServerProvider` god object - see
|
||||
// `.claude/context/architecture.md`'s "State management" section.
|
||||
// Order matters: each `create` below reads earlier providers via
|
||||
// `context.read`, which is safe here since none of them are ever
|
||||
// replaced/recreated for the app's lifetime (account switching is
|
||||
// internal state on SessionController, not a new provider instance).
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => ServerProvider(),
|
||||
create: (context) => SessionController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||
// Before SyncStatusController, which follows its Files Cache rule
|
||||
// for how often synced files refresh.
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => FilesController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
// Eager (`lazy: false`) - this controller only loads its persisted
|
||||
// state when SessionController fires the account-ready event, and
|
||||
// that fires once at startup, before any widget has read this
|
||||
// provider. Lazily created, it would register its listener too
|
||||
// late and stay empty ("Sync off") until an account switch.
|
||||
lazy: false,
|
||||
create: (context) =>
|
||||
SyncStatusController(context.read(), context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => PhotosController(context.read(), context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) =>
|
||||
FavoritesController(context.read(), context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => TrashController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => SharesController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => RecentController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => PickController(context.read()),
|
||||
),
|
||||
ChangeNotifierProvider(
|
||||
// Eager for the same reason as SyncStatusController above.
|
||||
lazy: false,
|
||||
create: (context) =>
|
||||
OfflineController(context.read(), context.read()),
|
||||
),
|
||||
Provider(
|
||||
create: (context) => ItemOperations(
|
||||
session: context.read(),
|
||||
files: context.read(),
|
||||
photos: context.read(),
|
||||
favorites: context.read(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const NextcloudApp(),
|
||||
),
|
||||
);
|
||||
@@ -28,7 +120,8 @@ class NextcloudApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ServerProvider>();
|
||||
final settings = context.watch<SettingsController>();
|
||||
final session = context.watch<SessionController>();
|
||||
|
||||
return DynamicColorBuilder(
|
||||
builder: (lightDynamic, darkDynamic) {
|
||||
@@ -36,22 +129,22 @@ class NextcloudApp extends StatelessWidget {
|
||||
title: 'Noo',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(
|
||||
provider.seedColor,
|
||||
settings.seedColor,
|
||||
dynamicScheme: lightDynamic,
|
||||
useDynamicColor: provider.useDynamicColor,
|
||||
useDynamicColor: settings.useDynamicColor,
|
||||
),
|
||||
darkTheme: AppTheme.dark(
|
||||
provider.seedColor,
|
||||
settings.seedColor,
|
||||
dynamicScheme: darkDynamic,
|
||||
useDynamicColor: provider.useDynamicColor,
|
||||
amoled: provider.amoledDark,
|
||||
useDynamicColor: settings.useDynamicColor,
|
||||
amoled: settings.amoledDark,
|
||||
),
|
||||
themeMode: provider.themeMode,
|
||||
home: provider.isRestoringSession
|
||||
themeMode: settings.themeMode,
|
||||
home: session.isRestoringSession
|
||||
? const _SplashView()
|
||||
: (!provider.isLoggedIn
|
||||
: (!session.isLoggedIn
|
||||
? const LoginView()
|
||||
: (provider.needsUnlock
|
||||
: (session.needsUnlock
|
||||
? const LockScreenView()
|
||||
: const MainShellView())),
|
||||
);
|
||||
@@ -65,21 +158,19 @@ class _SplashView extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final colors = context.nooColors;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// The monochrome adaptive-icon layer is a plain white silhouette
|
||||
// on transparent, meant to be tinted rather than shown as-is -
|
||||
// srcIn recolors it to the theme's foreground so it reads
|
||||
// correctly in both light and dark mode.
|
||||
// srcIn recolors it to fg-1 so it reads correctly in both light
|
||||
// and dark mode.
|
||||
ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
colorScheme.onSurface,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
||||
child: Image.asset(
|
||||
'assets/icon/app_icon_monochrome.png',
|
||||
width: 72,
|
||||
@@ -92,7 +183,7 @@ class _SplashView extends StatelessWidget {
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.primary,
|
||||
color: colors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -113,6 +204,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
late AppTab _currentTab;
|
||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||
StreamSubscription<PickRequest>? _pickSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -123,7 +215,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
// Preferences load asynchronously, but in practice finish well before
|
||||
// session restore (which also has to hit the network) does, so by the
|
||||
// time this shell mounts `defaultTab` already reflects the saved value.
|
||||
_currentTab = context.read<ServerProvider>().defaultTab;
|
||||
_currentTab = context.read<SettingsController>().defaultTab;
|
||||
|
||||
// Handles both a cold start via another app's "Share to..." sheet
|
||||
// (getInitialShare) and a share arriving while the app is already
|
||||
@@ -133,6 +225,72 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
// file's actual bytes, so a large shared file can't block startup.
|
||||
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
||||
|
||||
// Same cold-start-vs-already-running split as the share intent above:
|
||||
// another app may have launched Noo as its GET_CONTENT picker.
|
||||
PickIntentService.getPickRequest().then((request) {
|
||||
if (request != null && mounted) {
|
||||
context.read<PickController>().setPickRequest(request);
|
||||
}
|
||||
});
|
||||
_pickSub = PickIntentService.onNewPickRequest.listen((request) {
|
||||
if (mounted) context.read<PickController>().setPickRequest(request);
|
||||
});
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _maybeRequestNotificationPermission(),
|
||||
);
|
||||
}
|
||||
|
||||
static const _prefNotificationPermissionAsked =
|
||||
'notification_permission_asked';
|
||||
|
||||
// Asked once, on the app's first launch after login, rather than every
|
||||
// time this shell mounts - a plain-language reason shown before the OS
|
||||
// prompt (which just says "Noo would like to send you notifications"
|
||||
// with no context) so the system dialog doesn't feel unexplained, since
|
||||
// it's what shows upload/download progress for background transfers.
|
||||
Future<void> _maybeRequestNotificationPermission() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getBool(_prefNotificationPermissionAsked) ?? false) return;
|
||||
await prefs.setBool(_prefNotificationPermissionAsked, true);
|
||||
|
||||
final status = await Permission.notification.status;
|
||||
if (status.isGranted || status.isPermanentlyDenied) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final colors = context.nooColors;
|
||||
final shouldRequest = await showNooDialog<bool>(
|
||||
context,
|
||||
title: 'Allow notifications?',
|
||||
barrierDismissible: false,
|
||||
children: [
|
||||
Text(
|
||||
'Noo shows upload and download progress as a notification, so '
|
||||
'you can track transfers that keep running in the background.',
|
||||
style: NooText.body.copyWith(color: colors.fg2),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
spacing: 10,
|
||||
children: [
|
||||
NooButton(
|
||||
variant: NooButtonVariant.secondary,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Not now'),
|
||||
),
|
||||
NooButton(
|
||||
variant: NooButtonVariant.primary,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Allow'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
if (shouldRequest == true) {
|
||||
await Permission.notification.request();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSharedFiles(List<SharedFileRef> files) {
|
||||
@@ -151,60 +309,265 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
c.dispose();
|
||||
}
|
||||
_shareSub?.cancel();
|
||||
_pickSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ServerProvider>();
|
||||
final colors = context.nooColors;
|
||||
final settings = context.watch<SettingsController>();
|
||||
final pick = context.watch<PickController>();
|
||||
final connectivity = context.watch<ConnectivityController>();
|
||||
final session = context.watch<SessionController>();
|
||||
final quota = context.watch<FilesController>().quota;
|
||||
// Cheap and already loaded once the Trash tab has been visited this
|
||||
// session - not worth adding a fetch just to populate a nav badge.
|
||||
final trashCount = context.watch<TrashController>().items.length;
|
||||
|
||||
// A one-shot request (e.g. tapping a search result) to switch tabs -
|
||||
// consumed here so it only fires once, then cleared after this frame
|
||||
// (clearing it synchronously would call notifyListeners mid-build).
|
||||
final requestedTab = provider.requestedTab;
|
||||
final requestedTab = settings.requestedTab;
|
||||
if (requestedTab != null) {
|
||||
_currentTab = requestedTab;
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => provider.consumeRequestedTab(),
|
||||
(_) => settings.consumeRequestedTab(),
|
||||
);
|
||||
}
|
||||
|
||||
final visible = provider.visibleTabs;
|
||||
final selectedTab = visible.contains(_currentTab)
|
||||
? _currentTab
|
||||
: (visible.isNotEmpty ? visible.first : AppTab.files);
|
||||
final selectedIndex = visible
|
||||
.indexOf(selectedTab)
|
||||
.clamp(0, visible.isEmpty ? 0 : visible.length - 1);
|
||||
|
||||
final navItems = visible
|
||||
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
|
||||
final pickRequest = pick.pickRequest;
|
||||
// While acting as another app's picker, only Files and Photos make
|
||||
// sense as browsable sources - Trash/Shares/Activity/Recent aren't
|
||||
// real "pick a file from here" destinations, and only these two tabs'
|
||||
// views even know how to handle picking-mode taps. This overrides the
|
||||
// user's own hidden/reordered tab settings rather than respecting
|
||||
// them, since picking is a separate mode from normal browsing. With
|
||||
// no network at all, every other tab would just show its own loading
|
||||
// spinner forever/an error state, so the nav collapses to just Offline
|
||||
// - the one tab that works without a connection (see
|
||||
// `FilesView(offline: true)`/`ConnectivityController`).
|
||||
final overrideActive = pickRequest != null || connectivity.isOffline;
|
||||
final pinnedTabs = pickRequest != null
|
||||
? [AppTab.files, AppTab.photos]
|
||||
: connectivity.isOffline
|
||||
? [AppTab.offline]
|
||||
: settings.visibleTabs;
|
||||
// The drawer/sidebar's "More" section - hidden while an override above
|
||||
// is active, same as the drawer itself being unavailable during pick.
|
||||
final hiddenTabs = overrideActive
|
||||
? const <AppTab>[]
|
||||
: settings.tabOrder
|
||||
.where((t) => settings.hiddenTabs.contains(t))
|
||||
.toList();
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
// A tab opened from "More" isn't one of the pinned destinations, but
|
||||
// should stay shown and selected (with no destination highlighted in
|
||||
// the bottom bar/sidebar) until the user taps a pinned or another
|
||||
// "More" tab.
|
||||
final displayTabs = !overrideActive && !pinnedTabs.contains(_currentTab)
|
||||
? [...pinnedTabs, _currentTab]
|
||||
: pinnedTabs;
|
||||
final selectedTab = displayTabs.contains(_currentTab)
|
||||
? _currentTab
|
||||
: (displayTabs.isNotEmpty ? displayTabs.first : AppTab.files);
|
||||
final selectedIndex = displayTabs
|
||||
.indexOf(selectedTab)
|
||||
.clamp(0, displayTabs.isEmpty ? 0 : displayTabs.length - 1);
|
||||
final pinnedIndex = pinnedTabs.indexOf(selectedTab);
|
||||
|
||||
void selectTab(AppTab tab) {
|
||||
if (tab == _currentTab) return;
|
||||
setState(() => _currentTab = tab);
|
||||
}
|
||||
|
||||
String? trashBadge(AppTab tab) =>
|
||||
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null;
|
||||
|
||||
final isDesktop = NooLayout.isDesktop(context);
|
||||
final navStyle = NooLayout.navStyle(context);
|
||||
// Picking/offline already override the tab set itself (see pinnedTabs
|
||||
// above) - Search doesn't belong in either: there's nothing to search
|
||||
// for a file-picking flow, and Search needs the network Offline mode
|
||||
// doesn't have.
|
||||
final showBottomBarSearch = !overrideActive && settings.searchInBottomBar;
|
||||
|
||||
// Each tab renders its own sticky selection toolbar inline (right under
|
||||
// its sort/filter row) instead of the shell swapping in a shared one,
|
||||
// so the nav chrome stays put and usable regardless of selection state.
|
||||
//
|
||||
// Each tab also builds its own [AppTopBar] (labelled for that tab, not
|
||||
// just whichever is currently selected) and plants it as that tab's own
|
||||
// first sliver - see `buildAppTabView`'s doc comment and `topBarSliver`
|
||||
// in `tabs/tab_state_slivers.dart` - instead of one shared instance
|
||||
// living in `Scaffold.appBar`. That's what gives each tab's top bar
|
||||
// Material's native floating-away-on-scroll-down/reappear-on-scroll-up
|
||||
// behavior, tied to that tab's own `ScrollController`: a fixed
|
||||
// `Scaffold.appBar` can't do that (no per-tab scroll signal reaches
|
||||
// it), and a shared single instance can't show 7 different tab labels
|
||||
// at once now that every tab keeps its own independent scroll state.
|
||||
// Null on desktop (which shows `NooToolbar` instead - embedding it here
|
||||
// too, since `tabStack` is shared by both layouts below, would double
|
||||
// up the top chrome there) and while picking (no top bar at all, same
|
||||
// as this screen's old `Scaffold.appBar: pickRequest == null ? ... :
|
||||
// null`).
|
||||
final tabStack = Stack(
|
||||
children: [
|
||||
IndexedStack(
|
||||
index: selectedIndex,
|
||||
children: visible
|
||||
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
|
||||
children: displayTabs
|
||||
.map(
|
||||
(tab) => buildAppTabView(
|
||||
tab,
|
||||
_scrollControllers[tab]!,
|
||||
topBar: isDesktop || pickRequest != null
|
||||
? null
|
||||
: AppTopBar(
|
||||
style: navStyle,
|
||||
tab: tab,
|
||||
searchInBottomBar: showBottomBarSearch,
|
||||
navMenuStyle: settings.navMenuStyle,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
// Each tab now renders its own sticky selection toolbar inline
|
||||
// (right under its sort/filter row) instead of this shell
|
||||
// swapping in a shared floating bar - the bottom nav stays put
|
||||
// and usable regardless of selection state.
|
||||
FloatingBottomNavBar(
|
||||
selectedIndex: selectedIndex,
|
||||
items: navItems,
|
||||
opacity: provider.bottomBarOpacity,
|
||||
blurSigma: provider.bottomBarBlur,
|
||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||
],
|
||||
);
|
||||
|
||||
final canUpload =
|
||||
pickRequest == null &&
|
||||
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
|
||||
|
||||
Widget scaffold;
|
||||
if (isDesktop) {
|
||||
scaffold = Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
// Tablets keep the system bars, so inset for them (desktop's
|
||||
// insets are zero).
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
NooSidebar(
|
||||
account: NooSidebarAccount(
|
||||
avatar: NooAvatar(
|
||||
initials: accountInitial(session.username),
|
||||
current: true,
|
||||
size: 32,
|
||||
),
|
||||
name: session.username,
|
||||
subtitle: serverHost(session.serverUrl),
|
||||
onTap: () => showAccountSwitcher(context),
|
||||
),
|
||||
items: [
|
||||
for (final tab in pinnedTabs)
|
||||
NooSidebarItem(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
count: trashBadge(tab),
|
||||
selected: tab == selectedTab,
|
||||
onTap: () => selectTab(tab),
|
||||
),
|
||||
for (final tab in hiddenTabs)
|
||||
NooSidebarItem(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
count: trashBadge(tab),
|
||||
selected: tab == selectedTab,
|
||||
onTap: () => selectTab(tab),
|
||||
),
|
||||
],
|
||||
storage: NooSidebarStorage(
|
||||
value: quotaFraction(quota),
|
||||
detail: quotaDetail(quota),
|
||||
),
|
||||
settings: NooSidebarItem(
|
||||
icon: LucideIcons.settings,
|
||||
label: 'Settings',
|
||||
onTap: () => openSettings(context),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
NooToolbar(
|
||||
title: selectedTab.label,
|
||||
search: const ShellSearchLauncher(onSurface: true),
|
||||
actions: [
|
||||
if (canUpload)
|
||||
NooButton(
|
||||
icon: LucideIcons.upload,
|
||||
onTap: () => showCreateMenu(context),
|
||||
child: const Text('Upload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
final bottomBarStyle = settings.bottomBarStyle;
|
||||
scaffold = Scaffold(
|
||||
backgroundColor: colors.bg,
|
||||
drawerScrimColor: colors.scrim,
|
||||
// The top bar itself no longer lives here - see `tabStack` above.
|
||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||
// The avatar menu replaces the drawer, so the edge swipe must not
|
||||
// open it either.
|
||||
drawerEnableOpenDragGesture:
|
||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||
// Floating needs the body to draw behind the bar's own transparent
|
||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||
// short of it like attached does.
|
||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||
// rather than the Scaffold popping it fully in/out on every tab
|
||||
// switch - see NooFab's [collapsed].
|
||||
floatingActionButton:
|
||||
pickRequest == null && navStyle == NooNavStyle.android
|
||||
? NooFab(
|
||||
collapsed: switch (settings.fabStyle) {
|
||||
FabStyle.auto => !canUpload,
|
||||
FabStyle.mini => true,
|
||||
FabStyle.expanded => false,
|
||||
},
|
||||
barStyle: bottomBarStyle,
|
||||
onTap: () => showCreateMenu(context),
|
||||
)
|
||||
: null,
|
||||
body: tabStack,
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
style: navStyle,
|
||||
barStyle: bottomBarStyle,
|
||||
tabs: pinnedTabs,
|
||||
selectedIndex: pinnedIndex,
|
||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||
onDestinationSelected: (index) {
|
||||
final tappedTab = visible[index];
|
||||
final tappedTab = pinnedTabs[index];
|
||||
if (tappedTab == _currentTab) {
|
||||
if (provider.tapTabToScrollTop) {
|
||||
if (settings.tapTabToScrollTop) {
|
||||
final controller = _scrollControllers[tappedTab];
|
||||
if (controller != null && controller.hasClients) {
|
||||
// `hasClients` only means a position is attached - not that
|
||||
// it's finished its first layout. Calling animateTo before
|
||||
// that throws deep inside Flutter's ballistic-scroll code
|
||||
// (min/maxScrollExtent are still null), and since this
|
||||
// fires synchronously from the nav bar's tap handler, the
|
||||
// exception corrupts that tab's scroll view instead of
|
||||
// just being logged - a real crash seen in the wild
|
||||
// (Files rendering empty after re-tapping its own tab).
|
||||
if (controller != null &&
|
||||
controller.hasClients &&
|
||||
controller.position.hasContentDimensions) {
|
||||
controller.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@@ -214,18 +577,58 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_currentTab = tappedTab;
|
||||
});
|
||||
},
|
||||
onSearchTap: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SearchView()));
|
||||
selectTab(tappedTab);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
// While picking, system back cancels the pick (and tells the caller
|
||||
// nothing was chosen) instead of exiting the app from under it -
|
||||
// there's no Navigator route to pop at this, the root screen.
|
||||
canPop: pickRequest == null,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && pickRequest != null) pick.cancelPick();
|
||||
},
|
||||
child: scaffold,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocks interaction while the selected file(s) download to a local cache
|
||||
/// folder before being handed back to the caller (see
|
||||
/// `PickController.confirmPick`) - can take a moment for a large file/video.
|
||||
class _PickingProgressOverlay extends StatelessWidget {
|
||||
const _PickingProgressOverlay();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: colors.scrim,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(NooRadii.dialog),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: colors.accent),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Preparing file…',
|
||||
style: NooText.body.copyWith(color: colors.fg1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
/// Every destination the bottom nav bar can show. Order here is only the
|
||||
/// fallback default — actual order/visibility/default-tab are user
|
||||
/// configurable and persisted in [ServerProvider].
|
||||
enum AppTab { files, photos, activity, trash, shares, recent }
|
||||
/// configurable and persisted in `SettingsController`.
|
||||
enum AppTab {
|
||||
files,
|
||||
photos,
|
||||
favorites,
|
||||
activity,
|
||||
trash,
|
||||
shares,
|
||||
recent,
|
||||
offline,
|
||||
}
|
||||
|
||||
/// At most this many tabs may be visible in the bottom nav bar at once —
|
||||
/// the rest are reachable through the "more" dropdown instead.
|
||||
const int maxVisibleTabs = 5;
|
||||
/// the rest are reachable through the "more" dropdown instead. The
|
||||
/// effective cap a screen should actually use is
|
||||
/// [SettingsController.maxVisibleTabs], not this directly - it's one
|
||||
/// lower than this while `searchInBottomBar` is on, since Search then
|
||||
/// takes the freed-up slot itself rather than competing with the user's
|
||||
/// own reorderable tabs for one of these.
|
||||
const int defaultMaxVisibleTabs = 5;
|
||||
|
||||
extension AppTabInfo on AppTab {
|
||||
String get label {
|
||||
@@ -17,6 +31,8 @@ extension AppTabInfo on AppTab {
|
||||
return 'Files';
|
||||
case AppTab.photos:
|
||||
return 'Photos';
|
||||
case AppTab.favorites:
|
||||
return 'Favorites';
|
||||
case AppTab.activity:
|
||||
return 'Activity';
|
||||
case AppTab.trash:
|
||||
@@ -25,23 +41,31 @@ extension AppTabInfo on AppTab {
|
||||
return 'Shares';
|
||||
case AppTab.recent:
|
||||
return 'Recent';
|
||||
case AppTab.offline:
|
||||
return 'Offline';
|
||||
}
|
||||
}
|
||||
|
||||
// Lucide icon mapping per DESIGN_SYSTEM.md 1.5 - the Lucide icon slug is
|
||||
// named in a comment where it doesn't match the Dart identifier.
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case AppTab.files:
|
||||
return Icons.folder_rounded;
|
||||
return LucideIcons.folder;
|
||||
case AppTab.photos:
|
||||
return Icons.photo_library_rounded;
|
||||
return LucideIcons.images;
|
||||
case AppTab.favorites:
|
||||
return LucideIcons.star;
|
||||
case AppTab.activity:
|
||||
return Icons.electric_bolt_rounded;
|
||||
return LucideIcons.activity;
|
||||
case AppTab.trash:
|
||||
return Icons.delete_outline_rounded;
|
||||
return LucideIcons.trash2; // trash-2
|
||||
case AppTab.shares:
|
||||
return Icons.groups_rounded;
|
||||
return LucideIcons.share2; // share-2
|
||||
case AppTab.recent:
|
||||
return Symbols.search_activity_rounded;
|
||||
return LucideIcons.clock;
|
||||
case AppTab.offline:
|
||||
return LucideIcons.hardDriveDownload; // hard-drive-download
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'nextcloud_item.dart';
|
||||
|
||||
/// A resolution choice for one conflicting item in a Move/Copy batch -
|
||||
/// keyed by `item.id` in [ConflictChoice] maps passed to
|
||||
/// `ItemOperations.resolveConflicts`.
|
||||
enum ConflictChoice { overwrite, keepBoth, skip }
|
||||
|
||||
/// One item that couldn't be moved/copied because something with the same
|
||||
/// name already exists at the destination (a WebDAV 412 response).
|
||||
class MoveCopyConflict {
|
||||
final NextcloudItem item;
|
||||
|
||||
const MoveCopyConflict(this.item);
|
||||
}
|
||||
|
||||
/// Outcome of a Move/Copy batch (an initial attempt via
|
||||
/// `ItemOperations.moveItems`/`copyItems`, or a follow-up
|
||||
/// `resolveConflicts` call). [blockedReason] is set instead of attempting
|
||||
/// anything at all when the destination itself is invalid (e.g. moving a
|
||||
/// folder into its own subfolder).
|
||||
class MoveCopyResult {
|
||||
final int succeeded;
|
||||
final List<MoveCopyConflict> conflicts;
|
||||
final int failed;
|
||||
final String? blockedReason;
|
||||
|
||||
const MoveCopyResult({
|
||||
this.succeeded = 0,
|
||||
this.conflicts = const [],
|
||||
this.failed = 0,
|
||||
this.blockedReason,
|
||||
});
|
||||
}
|
||||
@@ -39,4 +39,20 @@ class NextcloudShare {
|
||||
});
|
||||
|
||||
bool get isFolder => itemType == NextcloudItemType.folder;
|
||||
|
||||
NextcloudShare withPermissions(int permissions) => NextcloudShare(
|
||||
id: id,
|
||||
path: path,
|
||||
name: name,
|
||||
itemType: itemType,
|
||||
shareType: shareType,
|
||||
ownerDisplayName: ownerDisplayName,
|
||||
sharedWithDisplayName: sharedWithDisplayName,
|
||||
sharedAt: sharedAt,
|
||||
sharedWithMe: sharedWithMe,
|
||||
url: url,
|
||||
permissions: permissions,
|
||||
token: token,
|
||||
expireDate: expireDate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/// Another app asking Noo (via Android's GET_CONTENT chooser) to pick a
|
||||
/// file/photo and hand it back - see `PickIntentService`/`MainActivity.kt`.
|
||||
class PickRequest {
|
||||
final String mimeType;
|
||||
final bool allowMultiple;
|
||||
final String? callerLabel;
|
||||
|
||||
const PickRequest({
|
||||
required this.mimeType,
|
||||
required this.allowMultiple,
|
||||
this.callerLabel,
|
||||
});
|
||||
|
||||
factory PickRequest.fromMap(Map<dynamic, dynamic> map) {
|
||||
return PickRequest(
|
||||
mimeType: map['mimeType'] as String? ?? '*/*',
|
||||
allowMultiple: map['allowMultiple'] as bool? ?? false,
|
||||
callerLabel: map['callerLabel'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether an item's mime type satisfies this request's filter (which may
|
||||
/// be a wildcard like `image/*` or the catch-all `*/*`).
|
||||
bool matches(String? itemMimeType) {
|
||||
if (mimeType == '*/*') return true;
|
||||
if (itemMimeType == null) return false;
|
||||
if (mimeType.endsWith('/*')) {
|
||||
return itemMimeType.startsWith(
|
||||
mimeType.substring(0, mimeType.length - 1),
|
||||
);
|
||||
}
|
||||
return itemMimeType == mimeType;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
/// A bulk action's identity, stable across the dynamic icon/label a
|
||||
/// [SelectionAction] actually renders (e.g. `favorite` covers both
|
||||
/// "Favorite" and "Remove from favorites"). This is what the user's
|
||||
/// configured [order] - Settings' "Action bar" reorder list - and
|
||||
/// [NooSelectionBar]'s inline/overflow split key off, since the live
|
||||
/// [SelectionAction] list is rebuilt fresh on every selection change and
|
||||
/// can't be compared by icon/label alone.
|
||||
///
|
||||
/// Not every screen offers every kind (e.g. Favorites has no `rename` or
|
||||
/// `sync`) - [orderSelectionActions] just skips whatever kind a screen
|
||||
/// didn't build.
|
||||
enum SelectionActionKind {
|
||||
favorite,
|
||||
share,
|
||||
download,
|
||||
delete,
|
||||
copy,
|
||||
move,
|
||||
rename,
|
||||
sync,
|
||||
details;
|
||||
|
||||
/// Generic label for the Settings reorder row - the live bar uses each
|
||||
/// screen's own contextual label (e.g. "Remove from favorites") instead.
|
||||
String get label => switch (this) {
|
||||
SelectionActionKind.favorite => 'Favorite',
|
||||
SelectionActionKind.share => 'Share',
|
||||
SelectionActionKind.download => 'Download',
|
||||
SelectionActionKind.delete => 'Delete',
|
||||
SelectionActionKind.copy => 'Copy',
|
||||
SelectionActionKind.move => 'Move',
|
||||
SelectionActionKind.rename => 'Rename',
|
||||
SelectionActionKind.sync => 'Sync to device',
|
||||
SelectionActionKind.details => 'Details',
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
SelectionActionKind.favorite => LucideIcons.star,
|
||||
SelectionActionKind.share => LucideIcons.share2,
|
||||
SelectionActionKind.download => LucideIcons.download,
|
||||
SelectionActionKind.delete => LucideIcons.trash2,
|
||||
SelectionActionKind.copy => LucideIcons.copy,
|
||||
SelectionActionKind.move => LucideIcons.folderInput,
|
||||
SelectionActionKind.rename => LucideIcons.filePen,
|
||||
SelectionActionKind.sync => LucideIcons.hardDriveDownload,
|
||||
SelectionActionKind.details => LucideIcons.info,
|
||||
};
|
||||
}
|
||||
|
||||
/// One bulk action available for the current multi-selection (e.g. favorite,
|
||||
/// download, delete), shown as an icon button in the sticky selection
|
||||
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
|
||||
class SelectionAction {
|
||||
final SelectionActionKind kind;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color? color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const SelectionAction({
|
||||
required this.kind,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reorders [actions] to match the user's configured [order] (Settings'
|
||||
/// "Action bar" section, [SelectionActionKind] values) - this is what makes
|
||||
/// that setting take effect, since it's [order] that decides which actions
|
||||
/// land in `NooSelectionBar`'s inline slots vs. behind "More". A kind in
|
||||
/// [order] the caller didn't build (e.g. `rename` on a multi-item
|
||||
/// selection) is just skipped; a kind [actions] has that [order] doesn't
|
||||
/// mention (e.g. a kind added in a later app update, before the saved
|
||||
/// order is migrated - see [SettingsController.selectionActionOrder]) is
|
||||
/// appended at the end in its original position.
|
||||
List<SelectionAction> orderSelectionActions(
|
||||
List<SelectionAction> actions,
|
||||
List<SelectionActionKind> order,
|
||||
) {
|
||||
final byKind = {for (final a in actions) a.kind: a};
|
||||
final ordered = <SelectionAction>[];
|
||||
for (final kind in order) {
|
||||
final action = byKind.remove(kind);
|
||||
if (action != null) ordered.add(action);
|
||||
}
|
||||
ordered.addAll(byKind.values);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/// A pending device-sync conflict (a file changed both locally and on the
|
||||
/// server since the last sync) - mirrors `SyncStatusBus.Conflict` on the
|
||||
/// native side exactly, see `SyncEngine.kt`/`server.md`'s "Device sync"
|
||||
/// section.
|
||||
class SyncConflictInfo {
|
||||
final String accountId;
|
||||
final String fileId;
|
||||
final String remotePath;
|
||||
final String relPath;
|
||||
final String name;
|
||||
|
||||
const SyncConflictInfo({
|
||||
required this.accountId,
|
||||
required this.fileId,
|
||||
required this.remotePath,
|
||||
required this.relPath,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory SyncConflictInfo.fromMap(Map<dynamic, dynamic> map) {
|
||||
return SyncConflictInfo(
|
||||
accountId: map['accountId'] as String? ?? '',
|
||||
fileId: map['fileId'] as String? ?? '',
|
||||
remotePath: map['remotePath'] as String? ?? '',
|
||||
relPath: map['relPath'] as String? ?? '',
|
||||
name: map['name'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single item's device-sync status, used to badge its tile in the
|
||||
/// Files view.
|
||||
enum SyncItemStatus { none, syncing, synced, conflict }
|
||||
|
||||
/// The persistent header chip/panel's overall status (account-wide, not
|
||||
/// scoped to whatever folder is currently browsed) - see
|
||||
/// `SyncStatusController.syncHeaderStatus`.
|
||||
enum SyncHeaderStatus { off, syncing, done, alert }
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Whether the device currently has *any* network route (Wi-Fi, mobile
|
||||
/// data, ethernet - not a guarantee the Nextcloud server itself is
|
||||
/// reachable, just OS-level connectivity). The single source of truth
|
||||
/// other controllers/views consult before attempting a network request or
|
||||
/// deciding whether to fall back to offline-only behavior - see
|
||||
/// `SessionController` (skips its login-restore HTTP call entirely while
|
||||
/// offline) and `MainShellView` (restricts the bottom nav to just the
|
||||
/// Offline tab while offline).
|
||||
class ConnectivityController extends ChangeNotifier {
|
||||
bool _isOffline = false;
|
||||
StreamSubscription<List<ConnectivityResult>>? _sub;
|
||||
Timer? _recheckTimer;
|
||||
|
||||
bool get isOffline => _isOffline;
|
||||
|
||||
ConnectivityController() {
|
||||
Connectivity().checkConnectivity().then(_apply);
|
||||
_sub = Connectivity().onConnectivityChanged.listen(_apply);
|
||||
// The very first check can spuriously report "no network" while the
|
||||
// OS's connectivity stack is still attaching callbacks to this
|
||||
// just-started process - a real Android cold-start quirk, not a
|
||||
// genuine transition. That matters here specifically because
|
||||
// onConnectivityChanged only fires on actual transitions, so a false
|
||||
// initial "offline" read would otherwise stick for the rest of the
|
||||
// session (SessionController would keep treating every login as
|
||||
// provisional, and every network-fetching controller would never get
|
||||
// its real activation signal - see addAccountReadyListener's doc
|
||||
// comment) even though the device was online the whole time. Re-
|
||||
// verify once, shortly after, to correct it.
|
||||
_recheckTimer = Timer(const Duration(seconds: 2), () {
|
||||
Connectivity().checkConnectivity().then(_apply);
|
||||
});
|
||||
}
|
||||
|
||||
void _apply(List<ConnectivityResult> results) {
|
||||
final offline =
|
||||
results.isEmpty || results.every((r) => r == ConnectivityResult.none);
|
||||
if (offline == _isOffline) return;
|
||||
_isOffline = offline;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
_recheckTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import 'files_controller.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// The Favorites tab: every favorited item across the whole account (not
|
||||
/// scoped to any one folder - a favorited item several folders deep still
|
||||
/// shows up regardless of whether its parent folders are themselves
|
||||
/// favorited, which is exactly why this is its own account-wide fetch
|
||||
/// rather than a filter toggle over whatever folder Files happens to be
|
||||
/// browsing - see `server.md`'s Favorites section for the full history of
|
||||
/// why a filter-toggle approach was tried and reverted). Depends on
|
||||
/// [FilesController] to reuse its exact
|
||||
/// display prefs (hidden/type-filter/sort/storage-scope) via
|
||||
/// [FilesController.applyFilesDisplayPrefs] - deliberately shared rather
|
||||
/// than a second parallel settings dimension.
|
||||
class FavoritesController extends ChangeNotifier {
|
||||
final SessionController session;
|
||||
final FilesController files;
|
||||
|
||||
List<NextcloudItem> _allFavorites = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
// True once fetchAll has run at least once (i.e. the Favorites tab has
|
||||
// been visited) - delete/rename/move/copy re-sync afterward, but only
|
||||
// when it's actually been loaded, so those actions don't pay for an
|
||||
// extra network round-trip on every Files/Photos edit for an account
|
||||
// that's never opened the Favorites tab this session.
|
||||
bool _everFetched = false;
|
||||
|
||||
FavoritesController(this.session, this.files) {
|
||||
session.addAccountClearedListener(_onAccountCleared);
|
||||
}
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
/// The Favorites tab's content, run through the exact same hidden/type-
|
||||
/// filter/sort/storage-scope display prefs as the Files tab. No
|
||||
/// favorites filter needed here - the source list is already
|
||||
/// all-favorites.
|
||||
List<NextcloudItem> get items => files.applyFilesDisplayPrefs(_allFavorites);
|
||||
|
||||
void _onAccountCleared() {
|
||||
_allFavorites = [];
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
_everFetched = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Loads every favorited item across the whole account for the Favorites
|
||||
/// tab.
|
||||
Future<void> fetchAll() async {
|
||||
final service = session.service;
|
||||
if (!session.isLoggedIn || service == null) return;
|
||||
final gen = session.sessionGeneration;
|
||||
_everFetched = true;
|
||||
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final favorites = await service.fetchFavorites();
|
||||
if (gen != session.sessionGeneration) return;
|
||||
_allFavorites = favorites;
|
||||
debugPrint('[FavoritesController] Loaded ${_allFavorites.length} favorites');
|
||||
} catch (e) {
|
||||
if (gen != session.sessionGeneration) return;
|
||||
debugPrint('[FavoritesController] Error fetching favorites: $e');
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
} finally {
|
||||
if (gen == session.sessionGeneration) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-syncs [_allFavorites] after a delete/rename/move/copy elsewhere in
|
||||
/// the app - those operations don't know how to patch this list in place
|
||||
/// (a move changes an item's path; WebDAV COPY's handling of custom
|
||||
/// properties like `oc:favorite` isn't reliable enough to assume the
|
||||
/// copy is still favorited), so this just refetches - but only if
|
||||
/// Favorites has actually been loaded this session. Called by
|
||||
/// `ItemOperations` after delete/rename/move/copy.
|
||||
Future<void> syncIfLoaded() {
|
||||
return _everFetched ? fetchAll() : Future.value();
|
||||
}
|
||||
|
||||
/// Patches a favorite-toggle result into [_allFavorites] in place (added
|
||||
/// if newly favorited, removed if un-favorited, updated otherwise) -
|
||||
/// called by `ItemOperations.toggleItemFavorite` after a successful
|
||||
/// server call. Cheap enough not to need a full refetch, unlike
|
||||
/// [syncIfLoaded].
|
||||
void applyFavoriteToggle(NextcloudItem updated) {
|
||||
final index = _allFavorites.indexWhere((i) => i.id == updated.id);
|
||||
if (updated.isFavorite) {
|
||||
if (index != -1) {
|
||||
_allFavorites[index] = updated;
|
||||
} else {
|
||||
_allFavorites = [..._allFavorites, updated];
|
||||
}
|
||||
} else if (index != -1) {
|
||||
_allFavorites = [..._allFavorites]..removeAt(index);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../services/upload_service.dart';
|
||||
import 'folder_browser.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
enum FileSortField { name, dateCreated, dateModified, size }
|
||||
|
||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external }
|
||||
|
||||
/// The Files tab's files/folders/both filter — independent of and applied
|
||||
/// after [StorageScope]/favorites/hidden filtering.
|
||||
enum FilesTypeFilter { all, filesOnly, foldersOnly }
|
||||
|
||||
/// How aggressively the Files tab's folder listings (not thumbnails/file
|
||||
/// content - just the list of names/sizes/dates) are reused across
|
||||
/// navigation instead of refetched from the server every time.
|
||||
enum CachePolicy {
|
||||
/// Every navigation refetches. Synced files are checked each time the app
|
||||
/// opens (and on pull-to-refresh), but not on a background schedule.
|
||||
never,
|
||||
|
||||
/// A listing is reused until it's older than [FilesController.
|
||||
/// cacheIntervalMinutes]; a timer also proactively refreshes the current
|
||||
/// folder on that same interval while the app is in the foreground.
|
||||
interval,
|
||||
|
||||
/// A listing is reused indefinitely until the user pulls to refresh.
|
||||
/// Synced files likewise only update on pull-to-refresh / "Sync now".
|
||||
manual,
|
||||
}
|
||||
|
||||
/// New accounts (and anyone who's never touched the setting) refresh
|
||||
/// periodically - the Files Cache rule also drives how often synced
|
||||
/// folders/files are brought in line with the server, see
|
||||
/// `SyncStatusController`.
|
||||
const defaultCachePolicy = CachePolicy.interval;
|
||||
const defaultCacheIntervalMinutes = 15;
|
||||
|
||||
/// One cached folder listing and when it was fetched.
|
||||
class _CachedDirectory {
|
||||
final List<NextcloudItem> items;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
const _CachedDirectory(this.items, this.fetchedAt);
|
||||
}
|
||||
|
||||
/// The Files tab: current folder/browsing state, display prefs (grid/list,
|
||||
/// hidden, type filter, storage scope, sort), the directory-listing cache,
|
||||
/// and - piggybacked onto the same fetch cycle as the original single
|
||||
/// provider did - account quota and the Activity tab's feed (neither is
|
||||
/// really "Files" data, but both were always fetched alongside the current
|
||||
/// folder's listing, not independently; preserved here as-is rather than
|
||||
/// invented a home for them, since untangling that wasn't asked for). Also
|
||||
/// the shared [storageScope]/[applyCommonFilters]/[applyFilesDisplayPrefs]
|
||||
/// logic Photos/Favorites both reuse - see their own controllers' doc
|
||||
/// comments for why they depend on this one.
|
||||
///
|
||||
/// Split out of the former single `ServerProvider` god object.
|
||||
class FilesController extends ChangeNotifier
|
||||
with WidgetsBindingObserver
|
||||
implements FolderBrowser {
|
||||
final SessionController session;
|
||||
|
||||
static const _prefGridView = 'ui_grid_view';
|
||||
static const _prefStorageScope = 'ui_storage_scope';
|
||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||
static const _prefFolderSort = 'ui_folder_sort';
|
||||
static const prefCachePolicy = 'ui_cache_policy';
|
||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
|
||||
String _currentFolderPath = '/';
|
||||
List<String> _pathStack = ['/'];
|
||||
|
||||
bool _isGridView = false;
|
||||
StorageScope _storageScope = StorageScope.cloud;
|
||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||
bool _showHiddenFiles = false;
|
||||
|
||||
final Map<String, FileSortField> _folderSortField = {};
|
||||
final Map<String, bool> _folderSortAscending = {};
|
||||
|
||||
CachePolicy _cachePolicy = defaultCachePolicy;
|
||||
int _cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||
final Map<String, _CachedDirectory> _directoryCache = {};
|
||||
Timer? _cacheRefreshTimer;
|
||||
|
||||
// Auto-retry of a failed (not in-place) load that looks like a network
|
||||
// hiccup - see [_scheduleNetworkRetry].
|
||||
Timer? _retryTimer;
|
||||
int _retryAttempt = 0;
|
||||
|
||||
List<NextcloudItem> _items = [];
|
||||
NextcloudUserQuota? _quota;
|
||||
List<NextcloudActivity> _activities = [];
|
||||
bool _isLoading = false;
|
||||
bool _activating = false;
|
||||
String? _errorMessage;
|
||||
|
||||
FilesController(this.session) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
session.addAccountClearedListener(_onAccountCleared);
|
||||
session.addAccountActivatedListener(_onAccountActivated);
|
||||
session.addAccountReadyListener(_restoreDisplayPrefs);
|
||||
// Refreshes automatically once an upload finishes, so a file shared/
|
||||
// uploaded into the folder currently on screen shows up without a
|
||||
// manual pull-to-refresh - uploads run in an Android foreground
|
||||
// service (see UploadService's doc comment) with no other way back to
|
||||
// this controller.
|
||||
_uploadSub = UploadService.completions.listen(_onUploadCompleted);
|
||||
}
|
||||
|
||||
StreamSubscription<UploadCompletion>? _uploadSub;
|
||||
|
||||
void _onUploadCompleted(UploadCompletion completion) {
|
||||
// refreshData() always hits the network regardless of cache freshness
|
||||
// (see its own doc comment), so there's nothing to invalidate first -
|
||||
// it overwrites this folder's cache entry once the fetch lands.
|
||||
if (completion.remoteFolder != _currentFolderPath) return;
|
||||
refreshData();
|
||||
}
|
||||
|
||||
// Getters
|
||||
@override
|
||||
String get currentFolderPath => _currentFolderPath;
|
||||
@override
|
||||
List<String> get pathStack => _pathStack;
|
||||
bool get isGridView => _isGridView;
|
||||
StorageScope get storageScope => _storageScope;
|
||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||
bool get showHiddenFiles => _showHiddenFiles;
|
||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||
|
||||
/// Per-folder sort prefs keyed by remote path - also what the Offline tab
|
||||
/// reads for the same path in the local mirror, so a folder sorts the
|
||||
/// same in both places.
|
||||
FileSortField sortFieldFor(String path) =>
|
||||
_folderSortField[path] ?? FileSortField.name;
|
||||
bool sortAscendingFor(String path) => _folderSortAscending[path] ?? true;
|
||||
CachePolicy get cachePolicy => _cachePolicy;
|
||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||
@override
|
||||
// Also true while a freshly activated account is still restoring prefs
|
||||
// and while the session verifies it, so a switch shows a spinner instead
|
||||
// of briefly flashing the empty-folder message.
|
||||
bool get isLoading => _isLoading || _activating || session.isLoading;
|
||||
@override
|
||||
String? get errorMessage => _errorMessage;
|
||||
NextcloudUserQuota? get quota => _quota;
|
||||
List<NextcloudActivity> get activities => _activities;
|
||||
|
||||
@override
|
||||
List<NextcloudItem> get items => applyFilesDisplayPrefs(_items);
|
||||
|
||||
int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) {
|
||||
switch (field) {
|
||||
case FileSortField.name:
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
case FileSortField.dateCreated:
|
||||
return a.dateCreated.compareTo(b.dateCreated);
|
||||
case FileSortField.dateModified:
|
||||
return a.lastModified.compareTo(b.lastModified);
|
||||
case FileSortField.size:
|
||||
return a.size.compareTo(b.size);
|
||||
}
|
||||
}
|
||||
|
||||
/// True if [item] or any ancestor folder in its path is a dotfile/dotfolder.
|
||||
bool _isHiddenItem(NextcloudItem item) {
|
||||
return item.path
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.any((segment) => segment.startsWith('.'));
|
||||
}
|
||||
|
||||
/// Applies the shared favorites-only/hidden-files/storage-scope toggles -
|
||||
/// shared by Files' own [items], Photos' `photoItems` (its own favorites-
|
||||
/// only/hidden toggles, but [storageScope] here), and Favorites'
|
||||
/// `favoriteItems` (via [applyFilesDisplayPrefs]).
|
||||
List<NextcloudItem> applyCommonFilters(
|
||||
List<NextcloudItem> source, {
|
||||
required bool showFavoritesOnly,
|
||||
required bool showHidden,
|
||||
bool applyStorageScope = true,
|
||||
}) {
|
||||
var filtered = source;
|
||||
if (showFavoritesOnly) {
|
||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||
}
|
||||
if (!showHidden) {
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
}
|
||||
if (applyStorageScope) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(item) => _storageScope == StorageScope.external
|
||||
? item.isExternalStorage
|
||||
: !item.isExternalStorage,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// Applies the Files tab's current sort/filter display prefs (hidden,
|
||||
/// type filter, storage scope, sort field/direction) to an arbitrary raw
|
||||
/// item list - factored out of the [items] getter so both the Favorites
|
||||
/// tab and the Move/Copy destination picker (which fetches its own
|
||||
/// listings via [fetchFolderListing] rather than reading [items] itself)
|
||||
/// can render with the exact same controls/behavior as the Files tab
|
||||
/// without duplicating this logic.
|
||||
///
|
||||
/// [folderPath] picks which folder's sort prefs apply (default: the
|
||||
/// Files tab's current folder). [applyStorageScope] is off for the
|
||||
/// Offline tab, whose local mirror has no cloud/external distinction.
|
||||
List<NextcloudItem> applyFilesDisplayPrefs(
|
||||
List<NextcloudItem> rawItems, {
|
||||
String? folderPath,
|
||||
bool applyStorageScope = true,
|
||||
}) {
|
||||
var filtered = applyCommonFilters(
|
||||
rawItems,
|
||||
// Files itself doesn't filter by favorite - that's the dedicated
|
||||
// Favorites tab's job.
|
||||
showFavoritesOnly: false,
|
||||
showHidden: _showHiddenFiles,
|
||||
applyStorageScope: applyStorageScope,
|
||||
);
|
||||
switch (_filesTypeFilter) {
|
||||
case FilesTypeFilter.all:
|
||||
break;
|
||||
case FilesTypeFilter.filesOnly:
|
||||
filtered = filtered.where((i) => !i.isFolder).toList();
|
||||
case FilesTypeFilter.foldersOnly:
|
||||
filtered = filtered.where((i) => i.isFolder).toList();
|
||||
}
|
||||
|
||||
final path = folderPath ?? _currentFolderPath;
|
||||
final field = sortFieldFor(path);
|
||||
final folders = filtered.where((i) => i.isFolder).toList()
|
||||
..sort((a, b) => _compareItems(a, b, field));
|
||||
final files = filtered.where((i) => !i.isFolder).toList()
|
||||
..sort((a, b) => _compareItems(a, b, field));
|
||||
return sortAscendingFor(path)
|
||||
? [...folders, ...files]
|
||||
: [...folders.reversed, ...files.reversed];
|
||||
}
|
||||
|
||||
void _onAccountCleared() {
|
||||
_prefsRestore = null;
|
||||
_retryTimer?.cancel();
|
||||
_retryAttempt = 0;
|
||||
_cacheRefreshTimer?.cancel();
|
||||
_directoryCache.clear();
|
||||
_folderSortField.clear();
|
||||
_folderSortAscending.clear();
|
||||
_items = [];
|
||||
_quota = null;
|
||||
_activities = [];
|
||||
_currentFolderPath = '/';
|
||||
_pathStack = ['/'];
|
||||
_isGridView = false;
|
||||
_storageScope = StorageScope.cloud;
|
||||
_filesTypeFilter = FilesTypeFilter.all;
|
||||
_showHiddenFiles = false;
|
||||
_cachePolicy = defaultCachePolicy;
|
||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Memoized per account (reset in _onAccountCleared) so the ready-event
|
||||
// and activated-event paths below share one restore instead of racing
|
||||
// two reads of the same prefs.
|
||||
Future<void>? _prefsRestore;
|
||||
|
||||
/// Loads this account's display prefs (grid/list, hidden, type filter,
|
||||
/// sort, cache policy) - hooked to `addAccountReadyListener` as well as
|
||||
/// activation so the Offline tab, which reads these same prefs, respects
|
||||
/// them even on a provisional/offline login where activation never fires.
|
||||
Future<void> _restoreDisplayPrefs() =>
|
||||
_prefsRestore ??= _doRestoreDisplayPrefs();
|
||||
|
||||
/// Completes once this account's cache policy/display prefs have been
|
||||
/// loaded from storage - lets `SyncStatusController` schedule background
|
||||
/// sync from the user's real policy instead of the defaults.
|
||||
Future<void> get displayPrefsLoaded => _restoreDisplayPrefs();
|
||||
|
||||
Future<void> _doRestoreDisplayPrefs() async {
|
||||
final id = session.activeAccountId;
|
||||
if (id != null) {
|
||||
// The whole prefs-restore block is wrapped, not just the JSON
|
||||
// decode below - a single bad/mistyped stored value here (an
|
||||
// unguarded `prefs.getBool`/`getString`/`getInt` throws if the
|
||||
// key holds a different runtime type than requested) would
|
||||
// otherwise propagate uncaught out of this method entirely,
|
||||
// silently skipping `refreshData()` below and leaving the Files
|
||||
// tab permanently empty on this activation with no error surfaced
|
||||
// anywhere - exactly what happened before this was guarded.
|
||||
try {
|
||||
final prefs = await session.prefsFuture;
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
|
||||
_isGridView = prefs.getBool(k(_prefGridView)) ?? false;
|
||||
final storageScopeName = prefs.getString(k(_prefStorageScope));
|
||||
_storageScope = StorageScope.values.firstWhere(
|
||||
(s) => s.name == storageScopeName,
|
||||
orElse: () => StorageScope.cloud,
|
||||
);
|
||||
final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter));
|
||||
_filesTypeFilter = FilesTypeFilter.values.firstWhere(
|
||||
(f) => f.name == filesTypeFilterName,
|
||||
orElse: () => FilesTypeFilter.all,
|
||||
);
|
||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||
|
||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||
if (folderSortJson != null) {
|
||||
try {
|
||||
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value as Map<String, dynamic>;
|
||||
final fieldName = value['field'] as String?;
|
||||
if (fieldName != null) {
|
||||
_folderSortField[entry.key] = FileSortField.values.firstWhere(
|
||||
(f) => f.name == fieldName,
|
||||
orElse: () => FileSortField.name,
|
||||
);
|
||||
}
|
||||
final ascending = value['ascending'] as bool?;
|
||||
if (ascending != null) {
|
||||
_folderSortAscending[entry.key] = ascending;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FilesController] Folder sort restore failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final cachePolicyName = prefs.getString(k(prefCachePolicy));
|
||||
_cachePolicy = CachePolicy.values.firstWhere(
|
||||
(c) => c.name == cachePolicyName,
|
||||
orElse: () => defaultCachePolicy,
|
||||
);
|
||||
_cacheIntervalMinutes =
|
||||
prefs.getInt(k(prefCacheIntervalMinutes)) ??
|
||||
defaultCacheIntervalMinutes;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[FilesController] Account-activation prefs restore failed: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onAccountActivated() async {
|
||||
_activating = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _restoreDisplayPrefs();
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
await refreshData();
|
||||
} finally {
|
||||
_activating = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Only relevant under CachePolicy.interval.
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_cacheRefreshTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
void _startCacheRefreshTimerIfNeeded() {
|
||||
_cacheRefreshTimer?.cancel();
|
||||
if (_cachePolicy != CachePolicy.interval || !session.isLoggedIn) return;
|
||||
_cacheRefreshTimer = Timer.periodic(
|
||||
Duration(minutes: _cacheIntervalMinutes),
|
||||
(_) => refreshData(),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCacheFresh(String path) {
|
||||
if (_cachePolicy == CachePolicy.never) return false;
|
||||
final cached = _directoryCache[path];
|
||||
if (cached == null) return false;
|
||||
if (_cachePolicy == CachePolicy.manual) return true;
|
||||
return DateTime.now().difference(cached.fetchedAt) <
|
||||
Duration(minutes: _cacheIntervalMinutes);
|
||||
}
|
||||
|
||||
Future<void> refreshData() async {
|
||||
final service = session.service;
|
||||
if (!session.isLoggedIn || service == null) {
|
||||
debugPrint(
|
||||
'[FilesController] refreshData skipped: isLoggedIn=${session.isLoggedIn}, service=${service != null}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Captured so a response landing after an account switch mid-flight
|
||||
// can recognize it's for an account the user has already left and
|
||||
// discard itself instead of overwriting the new account's content.
|
||||
final gen = session.sessionGeneration;
|
||||
_retryTimer?.cancel();
|
||||
|
||||
// Refreshing a folder that's already on screen (pull-to-refresh, the
|
||||
// periodic cache refresh) swaps the listing in place once the server
|
||||
// answers - blanking the list to a spinner first made every refresh
|
||||
// look like the files vanished and reappeared. The spinner is only for
|
||||
// when there's nothing to show yet (first load, just navigated into a
|
||||
// new folder - see [_navigateTo], which clears the outgoing folder's
|
||||
// items - or retrying after an error).
|
||||
final inPlace = _items.isNotEmpty && _errorMessage == null;
|
||||
if (!inPlace) {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'[FilesController] Refreshing data for path: $_currentFolderPath',
|
||||
);
|
||||
|
||||
try {
|
||||
final items = await service.fetchDirectory(_currentFolderPath);
|
||||
if (gen != session.sessionGeneration) return;
|
||||
_items = items;
|
||||
_directoryCache[_currentFolderPath] = _CachedDirectory(
|
||||
_items,
|
||||
DateTime.now(),
|
||||
);
|
||||
// Show the new listing now rather than after the quota/activity
|
||||
// fetches below - they're unrelated to what the list displays.
|
||||
_isLoading = false;
|
||||
_retryAttempt = 0;
|
||||
notifyListeners();
|
||||
debugPrint(
|
||||
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
|
||||
);
|
||||
try {
|
||||
_quota = await service.fetchUserQuota();
|
||||
} catch (e) {
|
||||
debugPrint('[FilesController] Quota fetch warning: $e');
|
||||
}
|
||||
try {
|
||||
_activities = await service.fetchActivities();
|
||||
} catch (e) {
|
||||
debugPrint('[FilesController] Activity fetch warning: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
if (gen != session.sessionGeneration) return;
|
||||
debugPrint('[FilesController] Error fetching directory: $e');
|
||||
// A failed in-place refresh keeps the (slightly stale) listing on
|
||||
// screen instead of replacing it with an error page.
|
||||
if (!inPlace) {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_scheduleNetworkRetry(e);
|
||||
}
|
||||
} finally {
|
||||
if (gen == session.sessionGeneration) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The OS reports "connected" a moment before the route actually works,
|
||||
/// so the first request after regaining Wi-Fi/data can fail with "Network
|
||||
/// is unreachable" - leaving the Files tab on an error page until the
|
||||
/// user taps Retry. Quietly retry a few times (2s, 4s, 8s, 15s) when the
|
||||
/// failure is network-level and the device is online; a real server error
|
||||
/// (auth, 5xx) isn't retried.
|
||||
void _scheduleNetworkRetry(Object error) {
|
||||
final text = error.toString();
|
||||
final isNetworkError =
|
||||
text.contains('SocketException') ||
|
||||
text.contains('ClientException') ||
|
||||
text.contains('TimeoutException') ||
|
||||
text.contains('Network is unreachable');
|
||||
if (!isNetworkError ||
|
||||
_retryAttempt >= 4 ||
|
||||
session.connectivity.isOffline) {
|
||||
return;
|
||||
}
|
||||
final delays = [2, 4, 8, 15];
|
||||
final delay = Duration(seconds: delays[_retryAttempt]);
|
||||
_retryAttempt++;
|
||||
_retryTimer = Timer(delay, () => unawaited(refreshData()));
|
||||
}
|
||||
|
||||
Future<List<NextcloudItem>> searchFiles(String query) async {
|
||||
final service = session.service;
|
||||
if (service == null) return [];
|
||||
return service.searchFiles(query);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reload() => refreshData();
|
||||
|
||||
@override
|
||||
Future<void> navigateToFolder(String path) async {
|
||||
_pathStack.add(path);
|
||||
await _navigateTo(path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> navigateUp() async {
|
||||
if (_pathStack.length > 1) {
|
||||
_pathStack.removeLast();
|
||||
await _navigateTo(_pathStack.last);
|
||||
}
|
||||
}
|
||||
|
||||
/// Jumps directly to an ancestor folder by its position in [pathStack]
|
||||
/// (as tapped from a breadcrumb), trimming everything below it.
|
||||
@override
|
||||
Future<void> navigateToPathIndex(int index) async {
|
||||
if (index < 0 || index >= _pathStack.length - 1) return;
|
||||
_pathStack = _pathStack.sublist(0, index + 1);
|
||||
await _navigateTo(_pathStack.last);
|
||||
}
|
||||
|
||||
/// Navigates directly to an arbitrary absolute folder path (e.g. from a
|
||||
/// search result) - unlike [navigateToFolder], which assumes [path] is a
|
||||
/// child of wherever the user is currently browsing and just appends it,
|
||||
/// this rebuilds the whole breadcrumb trail from root so it's correct
|
||||
/// regardless of where the user was before.
|
||||
Future<void> navigateToAbsoluteFolder(String path) async {
|
||||
final normalized = path.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
final segments = normalized.split('/').where((s) => s.isNotEmpty).toList();
|
||||
final stack = <String>['/'];
|
||||
var current = '';
|
||||
for (final segment in segments) {
|
||||
current = '$current/$segment';
|
||||
stack.add(current);
|
||||
}
|
||||
_pathStack = stack;
|
||||
await _navigateTo(stack.last);
|
||||
}
|
||||
|
||||
/// Switches the current folder to [path], serving its listing straight
|
||||
/// from the cache when that's still fresh (instant, no network call at
|
||||
/// all) and only falling back to [refreshData] otherwise.
|
||||
Future<void> _navigateTo(String path) async {
|
||||
_currentFolderPath = path;
|
||||
final cached = _directoryCache[path];
|
||||
if (cached != null && _isCacheFresh(path)) {
|
||||
debugPrint('[FilesController] Serving cached listing for $path');
|
||||
_items = cached.items;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
// Don't leave the folder just left on screen under the new breadcrumb
|
||||
// while this one loads - clearing it is what makes [refreshData] show
|
||||
// its spinner (rather than refreshing in place) for a navigation.
|
||||
_items = [];
|
||||
await refreshData();
|
||||
}
|
||||
|
||||
/// A destination-picker-only fetch: the current folder listing state
|
||||
/// (`items`/`currentFolderPath`/`pathStack`/the directory cache) belongs
|
||||
/// to whichever tab is actively browsing (Files), so the Move/Copy
|
||||
/// destination picker deliberately doesn't touch any of it - it fetches
|
||||
/// listings for its own local navigation state through this instead.
|
||||
Future<List<NextcloudItem>> fetchFolderListing(String path) {
|
||||
return session.service?.fetchDirectory(path) ?? Future.value([]);
|
||||
}
|
||||
|
||||
/// Finds the single item at [path] by listing its parent folder and
|
||||
/// matching on the exact path - there's no WebDAV call here for stat'ing
|
||||
/// one path directly outside a directory PROPFIND. Used by the Shares tab
|
||||
/// to open the full Share sheet for a [NextcloudShare], which only carries
|
||||
/// enough metadata for its own row, not the size/dates `ShareSheet`'s
|
||||
/// header needs.
|
||||
Future<NextcloudItem?> fetchItemAtPath(String path) async {
|
||||
final normalized = path.startsWith('/') ? path : '/$path';
|
||||
final lastSlash = normalized.lastIndexOf('/');
|
||||
final parent = lastSlash <= 0 ? '/' : normalized.substring(0, lastSlash);
|
||||
final items = await fetchFolderListing(parent);
|
||||
return items.where((i) => i.path == normalized).firstOrNull;
|
||||
}
|
||||
|
||||
void invalidateCache() => _directoryCache.clear();
|
||||
|
||||
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
|
||||
/// key. No-op if there's no active account (shouldn't normally happen -
|
||||
/// these setters are only reachable from screens that require one).
|
||||
void _persistAccountPref(
|
||||
String baseKey,
|
||||
void Function(SharedPreferences prefs, String key) write,
|
||||
) {
|
||||
final id = session.activeAccountId;
|
||||
if (id == null) return;
|
||||
session.prefsFuture.then(
|
||||
(p) => write(p, session.accountStore.accountPrefKey(id, baseKey)),
|
||||
);
|
||||
}
|
||||
|
||||
void setGridView(bool value) {
|
||||
if (_isGridView == value) return;
|
||||
_isGridView = value;
|
||||
notifyListeners();
|
||||
_persistAccountPref(_prefGridView, (p, key) => p.setBool(key, value));
|
||||
}
|
||||
|
||||
void setStorageScope(StorageScope scope) {
|
||||
if (_storageScope == scope) return;
|
||||
_storageScope = scope;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefStorageScope,
|
||||
(p, key) => p.setString(key, scope.name),
|
||||
);
|
||||
}
|
||||
|
||||
void setFilesTypeFilter(FilesTypeFilter filter) {
|
||||
if (_filesTypeFilter == filter) return;
|
||||
_filesTypeFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefFilesTypeFilter,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
||||
/// never exposes anything, so that direction is always allowed instantly.
|
||||
Future<void> toggleShowHiddenFiles() async {
|
||||
if (!_showHiddenFiles) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenFiles,
|
||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
||||
);
|
||||
}
|
||||
|
||||
void _persistFolderSort() {
|
||||
final combined = <String, dynamic>{};
|
||||
for (final path in {
|
||||
..._folderSortField.keys,
|
||||
..._folderSortAscending.keys,
|
||||
}) {
|
||||
combined[path] = {
|
||||
'field': _folderSortField[path]?.name,
|
||||
'ascending': _folderSortAscending[path],
|
||||
};
|
||||
}
|
||||
_persistAccountPref(
|
||||
_prefFolderSort,
|
||||
(p, key) => p.setString(key, jsonEncode(combined)),
|
||||
);
|
||||
}
|
||||
|
||||
void setFilesSortField(FileSortField field) =>
|
||||
setSortFieldFor(_currentFolderPath, field);
|
||||
|
||||
void toggleFilesSortOrder() => toggleSortOrderFor(_currentFolderPath);
|
||||
|
||||
void setSortFieldFor(String path, FileSortField field) {
|
||||
if (sortFieldFor(path) == field) return;
|
||||
_folderSortField[path] = field;
|
||||
notifyListeners();
|
||||
_persistFolderSort();
|
||||
}
|
||||
|
||||
void toggleSortOrderFor(String path) {
|
||||
_folderSortAscending[path] = !sortAscendingFor(path);
|
||||
notifyListeners();
|
||||
_persistFolderSort();
|
||||
}
|
||||
|
||||
void setCachePolicy(CachePolicy policy) {
|
||||
if (_cachePolicy == policy) return;
|
||||
_cachePolicy = policy;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
prefCachePolicy,
|
||||
(p, key) => p.setString(key, policy.name),
|
||||
);
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
}
|
||||
|
||||
void setCacheIntervalMinutes(int minutes) {
|
||||
final clamped = minutes.clamp(1, 60);
|
||||
if (_cacheIntervalMinutes == clamped) return;
|
||||
_cacheIntervalMinutes = clamped;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
prefCacheIntervalMinutes,
|
||||
(p, key) => p.setInt(key, clamped),
|
||||
);
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
}
|
||||
|
||||
Future<bool> createFolder(String folderName) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
final success = await service.createFolder(_currentFolderPath, folderName);
|
||||
if (success) {
|
||||
await refreshData();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Patches a favorite-toggle result into [items] in place (no refetch) -
|
||||
/// called by `ItemOperations.toggleItemFavorite` after a successful
|
||||
/// server call.
|
||||
void applyFavoriteToggle(NextcloudItem updated) {
|
||||
final index = _items.indexWhere((i) => i.id == updated.id);
|
||||
if (index != -1) _items[index] = updated;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_cacheRefreshTimer?.cancel();
|
||||
_uploadSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import '../models/nextcloud_item.dart';
|
||||
|
||||
/// What `FilesView` needs from whatever is supplying the folder it's
|
||||
/// showing: the breadcrumb trail, the (already display-pref-filtered and
|
||||
/// sorted) items, loading/error state, and navigation. Implemented by
|
||||
/// [FilesController] (the server's folders) and [OfflineController] (the
|
||||
/// device-sync mirror of those same folders) so the Files tab and the
|
||||
/// Offline tab are literally the same view over two data sources.
|
||||
///
|
||||
/// Deliberately not a `Listenable` - `context.watch` subscribes to the
|
||||
/// concrete provider registered in `main()`, not to this interface, so the
|
||||
/// view picks the concrete controller and then treats it as a
|
||||
/// [FolderBrowser].
|
||||
abstract interface class FolderBrowser {
|
||||
List<String> get pathStack;
|
||||
String get currentFolderPath;
|
||||
|
||||
/// Items in [currentFolderPath] with the shared Files display prefs
|
||||
/// (hidden files, type filter, sort) already applied.
|
||||
List<NextcloudItem> get items;
|
||||
|
||||
bool get isLoading;
|
||||
String? get errorMessage;
|
||||
|
||||
Future<void> navigateToFolder(String path);
|
||||
Future<void> navigateToPathIndex(int index);
|
||||
Future<void> navigateUp();
|
||||
|
||||
/// Re-reads the current folder (pull-to-refresh / retry).
|
||||
Future<void> reload();
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/move_copy_result.dart';
|
||||
import '../models/nextcloud_file_version.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../models/nextcloud_share.dart';
|
||||
import '../models/nextcloud_sharee.dart';
|
||||
import 'favorites_controller.dart';
|
||||
import 'files_controller.dart';
|
||||
import 'photos_controller.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// The one place that knows how an item mutation (delete/rename/move/copy/
|
||||
/// favorite-toggle/create-folder) ripples across Files/Photos/Favorites -
|
||||
/// those three each own their own item list, but a mutation triggered from
|
||||
/// *any* of them (see each call site in the views) needs to reconcile all
|
||||
/// three, since e.g. a delete removes an item Photos might also be
|
||||
/// showing. Deliberately holds direct references to the sibling
|
||||
/// controllers (constructed once in `main.dart`, after they already exist)
|
||||
/// rather than a generic event bus, so callers can still `await` a
|
||||
/// mutation and know every affected list has already been reconciled by
|
||||
/// the time it returns - exactly the same guarantee the single
|
||||
/// `ServerProvider` god object gave for free before this was split out.
|
||||
///
|
||||
/// Also home to the file-details sheet's per-item pass-throughs (shares/
|
||||
/// activity/versions) - stateless, no stored data, so they don't need a
|
||||
/// controller of their own.
|
||||
class ItemOperations {
|
||||
final SessionController session;
|
||||
final FilesController files;
|
||||
final PhotosController photos;
|
||||
final FavoritesController favorites;
|
||||
|
||||
const ItemOperations({
|
||||
required this.session,
|
||||
required this.files,
|
||||
required this.photos,
|
||||
required this.favorites,
|
||||
});
|
||||
|
||||
Future<bool> deleteItem(String itemPath) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
final success = await service.deleteItem(itemPath);
|
||||
if (success) {
|
||||
photos.applyDelete(itemPath);
|
||||
await files.refreshData();
|
||||
await favorites.syncIfLoaded();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<bool> renameItem(NextcloudItem item, String newName) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
final success = await service.renameItem(item.path, newName);
|
||||
if (success) {
|
||||
await files.refreshData();
|
||||
await favorites.syncIfLoaded();
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// True if [destFolderPath] is [folderPath] itself or one of its own
|
||||
/// descendants - moving/copying a folder into itself (or a subfolder of
|
||||
/// itself) is nonsensical and rejected client-side rather than left to
|
||||
/// the server to (maybe) reject.
|
||||
bool _isSelfOrDescendant(String destFolderPath, String folderPath) {
|
||||
final dest = destFolderPath.endsWith('/')
|
||||
? destFolderPath
|
||||
: '$destFolderPath/';
|
||||
final folder = folderPath.endsWith('/') ? folderPath : '$folderPath/';
|
||||
return dest == folder || dest.startsWith(folder);
|
||||
}
|
||||
|
||||
Future<MoveCopyResult> _moveOrCopyItems(
|
||||
List<NextcloudItem> items,
|
||||
String destFolderPath, {
|
||||
required bool copy,
|
||||
}) async {
|
||||
final service = session.service;
|
||||
if (service == null || items.isEmpty) return const MoveCopyResult();
|
||||
for (final item in items) {
|
||||
if (item.isFolder && _isSelfOrDescendant(destFolderPath, item.path)) {
|
||||
return const MoveCopyResult(
|
||||
blockedReason:
|
||||
"Can't move a folder into itself or one of its own subfolders",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var succeeded = 0;
|
||||
var failed = 0;
|
||||
final conflicts = <MoveCopyConflict>[];
|
||||
for (final item in items) {
|
||||
final status = copy
|
||||
? await service.copyItem(item.path, destFolderPath)
|
||||
: await service.moveItem(item.path, destFolderPath);
|
||||
if (status == 201 || status == 204) {
|
||||
succeeded++;
|
||||
} else if (status == 412) {
|
||||
conflicts.add(MoveCopyConflict(item));
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (succeeded > 0) {
|
||||
files.invalidateCache();
|
||||
await files.refreshData();
|
||||
await favorites.syncIfLoaded();
|
||||
}
|
||||
return MoveCopyResult(
|
||||
succeeded: succeeded,
|
||||
conflicts: conflicts,
|
||||
failed: failed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Moves every item in [items] into [destFolderPath], keeping each
|
||||
/// item's own filename. Items that would collide with something already
|
||||
/// there come back in [MoveCopyResult.conflicts] rather than failing
|
||||
/// outright - resolve those with [resolveConflicts].
|
||||
Future<MoveCopyResult> moveItems(
|
||||
List<NextcloudItem> items,
|
||||
String destFolderPath,
|
||||
) {
|
||||
return _moveOrCopyItems(items, destFolderPath, copy: false);
|
||||
}
|
||||
|
||||
/// Same as [moveItems] but duplicates rather than relocates.
|
||||
Future<MoveCopyResult> copyItems(
|
||||
List<NextcloudItem> items,
|
||||
String destFolderPath,
|
||||
) {
|
||||
return _moveOrCopyItems(items, destFolderPath, copy: true);
|
||||
}
|
||||
|
||||
/// Finds the first `name (n).ext` not already in [existing] - `existing`
|
||||
/// is mutated as names are claimed, so a batch of "keep both" resolutions
|
||||
/// never picks the same generated name twice.
|
||||
String _nextAvailableName(String name, Set<String> existing) {
|
||||
if (!existing.contains(name)) return name;
|
||||
final dotIndex = name.lastIndexOf('.');
|
||||
final hasExtension = dotIndex > 0 && dotIndex < name.length - 1;
|
||||
final base = hasExtension ? name.substring(0, dotIndex) : name;
|
||||
final ext = hasExtension ? name.substring(dotIndex) : '';
|
||||
var n = 2;
|
||||
while (existing.contains('$base ($n)$ext')) {
|
||||
n++;
|
||||
}
|
||||
return '$base ($n)$ext';
|
||||
}
|
||||
|
||||
/// Resolves a previous [moveItems]/[copyItems] call's conflicts per
|
||||
/// [choices] (keyed by `item.id`): overwrite the existing item, keep
|
||||
/// both (renamed against a fresh listing of [destFolderPath] so two
|
||||
/// "keep both" picks in the same batch can't collide with each other),
|
||||
/// or skip (left untouched at the source).
|
||||
Future<MoveCopyResult> resolveConflicts(
|
||||
List<MoveCopyConflict> conflicts,
|
||||
String destFolderPath, {
|
||||
required bool copy,
|
||||
required Map<String, ConflictChoice> choices,
|
||||
}) async {
|
||||
final service = session.service;
|
||||
if (service == null || conflicts.isEmpty) return const MoveCopyResult();
|
||||
|
||||
final needsKeepBoth = conflicts.any(
|
||||
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
||||
);
|
||||
final existingNames = needsKeepBoth
|
||||
? (await files.fetchFolderListing(
|
||||
destFolderPath,
|
||||
)).map((i) => i.name).toSet()
|
||||
: <String>{};
|
||||
|
||||
var succeeded = 0;
|
||||
var failed = 0;
|
||||
for (final conflict in conflicts) {
|
||||
final choice = choices[conflict.item.id] ?? ConflictChoice.skip;
|
||||
if (choice == ConflictChoice.skip) continue;
|
||||
|
||||
final int status;
|
||||
if (choice == ConflictChoice.overwrite) {
|
||||
status = copy
|
||||
? await service.copyItem(
|
||||
conflict.item.path,
|
||||
destFolderPath,
|
||||
overwrite: true,
|
||||
)
|
||||
: await service.moveItem(
|
||||
conflict.item.path,
|
||||
destFolderPath,
|
||||
overwrite: true,
|
||||
);
|
||||
} else {
|
||||
final newName = _nextAvailableName(conflict.item.name, existingNames);
|
||||
existingNames.add(newName);
|
||||
status = copy
|
||||
? await service.copyItem(
|
||||
conflict.item.path,
|
||||
destFolderPath,
|
||||
newName: newName,
|
||||
)
|
||||
: await service.moveItem(
|
||||
conflict.item.path,
|
||||
destFolderPath,
|
||||
newName: newName,
|
||||
);
|
||||
}
|
||||
if (status == 201 || status == 204) {
|
||||
succeeded++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
if (succeeded > 0) {
|
||||
files.invalidateCache();
|
||||
await files.refreshData();
|
||||
await favorites.syncIfLoaded();
|
||||
}
|
||||
return MoveCopyResult(succeeded: succeeded, failed: failed);
|
||||
}
|
||||
|
||||
Future<void> toggleItemFavorite(NextcloudItem item) async {
|
||||
final service = session.service;
|
||||
if (service == null) return;
|
||||
final success = await service.toggleFavorite(item.path, item.isFavorite);
|
||||
if (success) {
|
||||
final updated = item.copyWith(isFavorite: !item.isFavorite);
|
||||
files.applyFavoriteToggle(updated);
|
||||
photos.applyFavoriteToggle(updated);
|
||||
favorites.applyFavoriteToggle(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a public link share for [item] and returns its URL, or null on
|
||||
/// failure (server error, unsupported item, etc).
|
||||
Future<String?> createShareLink(NextcloudItem item) async {
|
||||
final service = session.service;
|
||||
if (service == null) return null;
|
||||
try {
|
||||
return await service.createPublicShareLink(item.path);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] createShareLink failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Details sheet: per-item sharing/activity/versions ---
|
||||
//
|
||||
// Thin pass-throughs (no stored state) - the data here is transient and
|
||||
// scoped to whichever item's Details sheet happens to be open, so it
|
||||
// lives in that sheet's own local widget state rather than a controller.
|
||||
|
||||
Future<List<NextcloudShare>> fetchItemShares(NextcloudItem item) async {
|
||||
final service = session.service;
|
||||
if (service == null) return [];
|
||||
try {
|
||||
return await service.fetchSharesForPath(item.path);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] fetchItemShares failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<NextcloudShare>> fetchInheritedShares(NextcloudItem item) async {
|
||||
final service = session.service;
|
||||
if (service == null) return [];
|
||||
return service.fetchInheritedShares(item.path);
|
||||
}
|
||||
|
||||
Future<List<NextcloudSharee>> searchSharees(String query) async {
|
||||
final service = session.service;
|
||||
if (service == null) return [];
|
||||
try {
|
||||
return await service.searchSharees(query);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] searchSharees failed: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<NextcloudShare?> createShare({
|
||||
required String path,
|
||||
required int shareType,
|
||||
String? shareWith,
|
||||
String? password,
|
||||
DateTime? expireDate,
|
||||
}) async {
|
||||
final service = session.service;
|
||||
if (service == null) return null;
|
||||
try {
|
||||
return await service.createShare(
|
||||
path: path,
|
||||
shareType: shareType,
|
||||
shareWith: shareWith,
|
||||
password: password,
|
||||
expireDate: expireDate,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] createShare failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateSharePermissions(
|
||||
NextcloudShare share,
|
||||
int permissions,
|
||||
) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
try {
|
||||
return await service.updateSharePermissions(share.id, permissions);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] updateSharePermissions failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a share (from the file-details/Share sheet, not the Shares
|
||||
/// tab itself - see `SharesController.deleteShare` for that one, which
|
||||
/// additionally patches the tab's own list).
|
||||
Future<bool> deleteShare(NextcloudShare share) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
return service.deleteShare(share.id);
|
||||
}
|
||||
|
||||
Future<List<NextcloudActivity>> fetchFileActivity(NextcloudItem item) async {
|
||||
final service = session.service;
|
||||
if (service == null) return [];
|
||||
return service.fetchFileActivity(item.id);
|
||||
}
|
||||
|
||||
Future<List<NextcloudFileVersion>> fetchFileVersions(
|
||||
NextcloudItem item,
|
||||
) async {
|
||||
final service = session.service;
|
||||
if (service == null) return [];
|
||||
return service.fetchFileVersions(item.id);
|
||||
}
|
||||
|
||||
Future<bool> restoreFileVersion(
|
||||
NextcloudItem item,
|
||||
String versionLabel,
|
||||
) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
try {
|
||||
return await service.restoreFileVersion(item.id, versionLabel);
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] restoreFileVersion failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> downloadVersion(
|
||||
NextcloudItem item,
|
||||
String versionLabel,
|
||||
String savePath,
|
||||
) async {
|
||||
final service = session.service;
|
||||
if (service == null) return false;
|
||||
try {
|
||||
await service.downloadVersionToFile(item.id, versionLabel, savePath);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[ItemOperations] downloadVersion failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../services/sync_service.dart';
|
||||
import 'files_controller.dart';
|
||||
import 'folder_browser.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// The Offline tab's data source: `FilesView` (the same view as the Files
|
||||
/// tab) over whatever device-sync has actually mirrored to local storage,
|
||||
/// i.e. the Files tab's folders filtered down to what's available offline -
|
||||
/// read straight off
|
||||
/// disk, no server round-trip, since the whole point is browsing what's
|
||||
/// available without a connection. Walks
|
||||
/// `<externalFilesDir>/sync/<accountId>/...` directly (mirrors
|
||||
/// `SyncEngine.kt#syncRoot`'s layout exactly, same technique
|
||||
/// `SyncStatusController.localSyncedFilePath` already relies on) rather
|
||||
/// than round-tripping through a MethodChannel. Listens for
|
||||
/// [SessionController.addAccountReadyListener] rather than
|
||||
/// `addAccountActivatedListener` - this controller's own work is entirely
|
||||
/// local, so it's safe to run even on a provisional/offline login (see
|
||||
/// that method's doc comment).
|
||||
///
|
||||
/// Display controls (grid/list, sort, hidden files, files/folders filter)
|
||||
/// aren't kept here - [items] runs the listing through the Files tab's own
|
||||
/// [FilesController] prefs so the two tabs always agree.
|
||||
class OfflineController extends ChangeNotifier
|
||||
with WidgetsBindingObserver
|
||||
implements FolderBrowser {
|
||||
final SessionController session;
|
||||
final FilesController files;
|
||||
|
||||
List<String> _pathStack = const ['/'];
|
||||
List<NextcloudItem> _items = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
bool _wasSyncing = false;
|
||||
// Resolved once in _load - lets [localFileFor] stay synchronous so it can
|
||||
// be called straight from a widget's build.
|
||||
String? _syncBasePath;
|
||||
StreamSubscription<SyncStatusSnapshot>? _statusSub;
|
||||
|
||||
OfflineController(this.session, this.files) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
session.addAccountClearedListener(_onAccountCleared);
|
||||
session.addAccountReadyListener(_onAccountActivated);
|
||||
// Refreshes automatically once a sync pass finishes, so newly
|
||||
// downloaded files show up here without a manual pull-to-refresh.
|
||||
_statusSub = SyncService.statusStream.listen(_onStatusSnapshot);
|
||||
}
|
||||
|
||||
@override
|
||||
List<String> get pathStack => _pathStack;
|
||||
@override
|
||||
String get currentFolderPath => _pathStack.last;
|
||||
@override
|
||||
List<NextcloudItem> get items => files.applyFilesDisplayPrefs(
|
||||
_items,
|
||||
folderPath: currentFolderPath,
|
||||
// The local mirror has no cloud/external distinction to filter on.
|
||||
applyStorageScope: false,
|
||||
);
|
||||
// Only "loading" while there's nothing to show yet - a reload over an
|
||||
// already-listed folder (e.g. the automatic one after a sync pass
|
||||
// finishes) swaps the list in place instead of flashing a spinner.
|
||||
@override
|
||||
bool get isLoading => _isLoading && _items.isEmpty;
|
||||
@override
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
void _onAccountCleared() {
|
||||
_pathStack = const ['/'];
|
||||
_items = [];
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onAccountActivated() {
|
||||
unawaited(_load());
|
||||
}
|
||||
|
||||
/// A background sync (which can run while the app is closed or paused)
|
||||
/// may have added or removed files since this list was last read - re-read
|
||||
/// the folder whenever the app comes back to the foreground.
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) unawaited(_load());
|
||||
}
|
||||
|
||||
void _onStatusSnapshot(SyncStatusSnapshot snapshot) {
|
||||
if (_wasSyncing && !snapshot.syncing) {
|
||||
unawaited(_load());
|
||||
}
|
||||
_wasSyncing = snapshot.syncing;
|
||||
}
|
||||
|
||||
/// Reloads the currently browsed folder - used by pull-to-refresh and as
|
||||
/// the account-activation entry point.
|
||||
@override
|
||||
Future<void> reload() => _load();
|
||||
|
||||
@override
|
||||
Future<void> navigateToFolder(String path) async {
|
||||
_pathStack = [..._pathStack, path];
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> navigateToPathIndex(int index) async {
|
||||
if (index < 0 || index >= _pathStack.length - 1) return;
|
||||
_pathStack = _pathStack.sublist(0, index + 1);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> navigateUp() async {
|
||||
if (_pathStack.length <= 1) return;
|
||||
_pathStack = _pathStack.sublist(0, _pathStack.length - 1);
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final accountId = session.activeAccountId;
|
||||
if (accountId == null) return;
|
||||
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
|
||||
// A sync can delete the very folder being browsed (deleted on the
|
||||
// server) - step out to the nearest ancestor that still exists rather
|
||||
// than showing a phantom empty folder.
|
||||
final base = _syncBasePath;
|
||||
if (base != null) {
|
||||
while (_pathStack.length > 1 &&
|
||||
!Directory(
|
||||
p.join(
|
||||
base,
|
||||
'sync',
|
||||
accountId,
|
||||
currentFolderPath.replaceFirst('/', ''),
|
||||
),
|
||||
).existsSync()) {
|
||||
_pathStack = _pathStack.sublist(0, _pathStack.length - 1);
|
||||
}
|
||||
}
|
||||
_items = await _listFolder(accountId, currentFolderPath);
|
||||
} catch (e) {
|
||||
debugPrint('[OfflineController] Error listing local files: $e');
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// The absolute local path for [item] - a pure function of its remote-
|
||||
/// style `path` (same `<accountId>/<relPath>` layout `SyncEngine.kt#
|
||||
/// syncRoot` uses). Deliberately doesn't re-verify [item] is "in sync
|
||||
/// scope" the way `SyncStatusController.localSyncedFilePath` does for
|
||||
/// arbitrary items from elsewhere in the app - every item this
|
||||
/// controller ever hands out already came from listing this exact
|
||||
/// directory tree, so it's definitionally already local; re-checking
|
||||
/// scope here was the actual bug behind a file the Offline tab had just
|
||||
/// listed reporting itself as "no longer available".
|
||||
/// Synchronous counterpart to [localPathFor] for building thumbnails -
|
||||
/// null until the first [_load] has resolved the storage directory. Doesn't
|
||||
/// check the file exists; the image widget's own error fallback covers a
|
||||
/// missing one.
|
||||
File? localFileFor(NextcloudItem item) {
|
||||
final accountId = session.activeAccountId;
|
||||
final base = _syncBasePath;
|
||||
if (accountId == null || base == null) return null;
|
||||
final relPath = item.path.startsWith('/')
|
||||
? item.path.substring(1)
|
||||
: item.path;
|
||||
return File(p.join(base, 'sync', accountId, relPath));
|
||||
}
|
||||
|
||||
Future<String?> localPathFor(NextcloudItem item) async {
|
||||
final accountId = session.activeAccountId;
|
||||
if (accountId == null) return null;
|
||||
final base = await getExternalStorageDirectory();
|
||||
if (base == null) return null;
|
||||
final relPath = item.path.startsWith('/')
|
||||
? item.path.substring(1)
|
||||
: item.path;
|
||||
final file = File(p.join(base.path, 'sync', accountId, relPath));
|
||||
return file.existsSync() ? file.path : null;
|
||||
}
|
||||
|
||||
static Future<List<NextcloudItem>> _listFolder(
|
||||
String accountId,
|
||||
String folderPath,
|
||||
) async {
|
||||
final base = await getExternalStorageDirectory();
|
||||
if (base == null) return [];
|
||||
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
||||
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
||||
if (!dir.existsSync()) return [];
|
||||
|
||||
final items = <NextcloudItem>[];
|
||||
await for (final entity in dir.list(followLinks: false)) {
|
||||
final name = p.basename(entity.path);
|
||||
final remotePath = folderPath == '/' ? '/$name' : '$folderPath/$name';
|
||||
if (entity is Directory) {
|
||||
items.add(
|
||||
NextcloudItem(
|
||||
id: remotePath,
|
||||
name: name,
|
||||
path: remotePath,
|
||||
type: NextcloudItemType.folder,
|
||||
size: 0,
|
||||
lastModified: (await entity.stat()).modified,
|
||||
),
|
||||
);
|
||||
} else if (entity is File) {
|
||||
final stat = await entity.stat();
|
||||
items.add(
|
||||
NextcloudItem(
|
||||
id: remotePath,
|
||||
name: name,
|
||||
path: remotePath,
|
||||
type: NextcloudItem.deduceType(remotePath, false, null),
|
||||
size: stat.size,
|
||||
lastModified: stat.modified,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_statusSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import 'files_controller.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// The Photos tab: every image/video across the whole account (not just
|
||||
/// the currently browsed folder - see [fetchAllMedia]). Depends on
|
||||
/// [FilesController] only for the shared [StorageScope] toggle and
|
||||
/// [FilesController.applyCommonFilters] - Photos' own favorites-only/
|
||||
/// hidden toggles are independent of Files', but the storage-scope split
|
||||
/// (cloud vs external) is one shared setting across every tab.
|
||||
class PhotosController extends ChangeNotifier {
|
||||
final SessionController session;
|
||||
final FilesController files;
|
||||
|
||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||
static const _prefSortField = 'ui_sort_field';
|
||||
static const _prefSortAscending = 'ui_sort_ascending';
|
||||
|
||||
List<NextcloudItem> _allMedia = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
bool _showFavoritesOnly = false;
|
||||
bool _showHidden = false;
|
||||
FileSortField _sortField = FileSortField.name;
|
||||
bool _sortAscending = true;
|
||||
|
||||
PhotosController(this.session, this.files) {
|
||||
session.addAccountClearedListener(_onAccountCleared);
|
||||
session.addAccountActivatedListener(_onAccountActivated);
|
||||
}
|
||||
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||
bool get showHidden => _showHidden;
|
||||
FileSortField get sortField => _sortField;
|
||||
bool get sortAscending => _sortAscending;
|
||||
|
||||
/// All images/videos across the whole account (not just the currently
|
||||
/// browsed folder) — see [fetchAllMedia].
|
||||
List<NextcloudItem> get items {
|
||||
final media = _allMedia.where((i) => i.isMedia).toList();
|
||||
final filtered = files.applyCommonFilters(
|
||||
media,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
showHidden: _showHidden,
|
||||
)..sort((a, b) => _compare(a, b));
|
||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||
}
|
||||
|
||||
int _compare(NextcloudItem a, NextcloudItem b) {
|
||||
switch (_sortField) {
|
||||
case FileSortField.name:
|
||||
return a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
case FileSortField.dateCreated:
|
||||
return a.dateCreated.compareTo(b.dateCreated);
|
||||
case FileSortField.dateModified:
|
||||
return a.lastModified.compareTo(b.lastModified);
|
||||
case FileSortField.size:
|
||||
return a.size.compareTo(b.size);
|
||||
}
|
||||
}
|
||||
|
||||
void _onAccountCleared() {
|
||||
_allMedia = [];
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _onAccountActivated() async {
|
||||
final id = session.activeAccountId;
|
||||
if (id != null) {
|
||||
// Wrapped so a single bad/mistyped stored pref value can't
|
||||
// silently skip fetchAllMedia() below and leave Photos permanently
|
||||
// empty on this activation with no error surfaced anywhere - see
|
||||
// FilesController._onAccountActivated's identical guard for the
|
||||
// real incident this mirrors.
|
||||
try {
|
||||
final prefs = await session.prefsFuture;
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
_showFavoritesOnly =
|
||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||
_sortField = FileSortField.values.firstWhere(
|
||||
(f) => f.name == sortFieldName,
|
||||
orElse: () => FileSortField.name,
|
||||
);
|
||||
_sortAscending = prefs.getBool(k(_prefSortAscending)) ?? true;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[PhotosController] Account-activation prefs restore failed: $e');
|
||||
}
|
||||
}
|
||||
unawaited(fetchAllMedia());
|
||||
}
|
||||
|
||||
/// Loads every image/video across the whole account for the Photos tab.
|
||||
Future<void> fetchAllMedia() async {
|
||||
final service = session.service;
|
||||
if (!session.isLoggedIn || service == null) return;
|
||||
final gen = session.sessionGeneration;
|
||||
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final media = await service.fetchAllMedia();
|
||||
if (gen != session.sessionGeneration) return;
|
||||
_allMedia = media;
|
||||
debugPrint('[PhotosController] Loaded ${_allMedia.length} media items');
|
||||
} catch (e) {
|
||||
if (gen != session.sessionGeneration) return;
|
||||
debugPrint('[PhotosController] Error fetching all media: $e');
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
} finally {
|
||||
if (gen == session.sessionGeneration) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _persistAccountPref(
|
||||
String baseKey,
|
||||
void Function(SharedPreferences prefs, String key) write,
|
||||
) {
|
||||
final id = session.activeAccountId;
|
||||
if (id == null) return;
|
||||
session.prefsFuture.then(
|
||||
(p) => write(p, session.accountStore.accountPrefKey(id, baseKey)),
|
||||
);
|
||||
}
|
||||
|
||||
void toggleFavoritesFilter() {
|
||||
_showFavoritesOnly = !_showFavoritesOnly;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowFavoritesOnlyPhotos,
|
||||
(p, key) => p.setBool(key, _showFavoritesOnly),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleShowHidden() async {
|
||||
if (!_showHidden) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHidden = !_showHidden;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenPhotos,
|
||||
(p, key) => p.setBool(key, _showHidden),
|
||||
);
|
||||
}
|
||||
|
||||
void setSortField(FileSortField field) {
|
||||
if (_sortField == field) return;
|
||||
_sortField = field;
|
||||
notifyListeners();
|
||||
_persistAccountPref(_prefSortField, (p, key) => p.setString(key, field.name));
|
||||
}
|
||||
|
||||
void toggleSortOrder() {
|
||||
_sortAscending = !_sortAscending;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefSortAscending,
|
||||
(p, key) => p.setBool(key, _sortAscending),
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes an item by path (a delete elsewhere in the app) - called by
|
||||
/// `ItemOperations.deleteItem` after a successful server call.
|
||||
void applyDelete(String path) {
|
||||
_allMedia = _allMedia.where((i) => i.path != path).toList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Patches a favorite-toggle result into [items]' source list in place -
|
||||
/// called by `ItemOperations.toggleItemFavorite` after a successful
|
||||
/// server call.
|
||||
void applyFavoriteToggle(NextcloudItem updated) {
|
||||
final index = _allMedia.indexWhere((i) => i.id == updated.id);
|
||||
if (index != -1) _allMedia[index] = updated;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import '../models/pick_request.dart';
|
||||
import '../services/pick_intent_service.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// State for Noo acting as *another* app's GET_CONTENT picker (see
|
||||
/// `PickIntentService`/`MainActivity.kt`) - non-null [pickRequest] while
|
||||
/// active, set at startup/onNewPickRequest in `MainShellView`, cleared
|
||||
/// once the pick is confirmed or cancelled. Cross-cutting (both Files and
|
||||
/// Photos need to know when picking mode is active and route taps through
|
||||
/// [confirmPick] instead of their normal open/select behavior), so this is
|
||||
/// its own small controller rather than living on any one tab's.
|
||||
class PickController extends ChangeNotifier {
|
||||
final SessionController session;
|
||||
|
||||
PickController(this.session);
|
||||
|
||||
PickRequest? _pickRequest;
|
||||
bool _isDownloadingForPick = false;
|
||||
|
||||
PickRequest? get pickRequest => _pickRequest;
|
||||
bool get isPicking => _pickRequest != null;
|
||||
bool get isDownloadingForPick => _isDownloadingForPick;
|
||||
|
||||
/// Whether [item] can be handed back to the app that's currently picking
|
||||
/// - always true for folders (still browsable), mime-filtered for files.
|
||||
bool itemMatchesPickFilter(NextcloudItem item) {
|
||||
if (item.isFolder) return true;
|
||||
final request = _pickRequest;
|
||||
return request == null || request.matches(item.mimeType);
|
||||
}
|
||||
|
||||
/// Called once (from MainShellView) as soon as a pick request is known -
|
||||
/// either the cold-start request or one that arrived via onNewPickRequest
|
||||
/// while already running.
|
||||
void setPickRequest(PickRequest request) {
|
||||
_pickRequest = request;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Downloads each selected item to a scratch cache folder, then hands the
|
||||
/// local paths back to the caller through PickIntentService, which closes
|
||||
/// the picker Activity on success. Left in [pickRequest] (i.e. picking
|
||||
/// mode stays visually active) if the download fails partway, so the user
|
||||
/// can see the error and retry rather than the screen finishing under
|
||||
/// them with nothing returned to the caller.
|
||||
Future<bool> confirmPick(List<NextcloudItem> items) async {
|
||||
final service = session.service;
|
||||
if (_pickRequest == null || service == null || items.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
_isDownloadingForPick = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final pickDir = Directory(p.join(tempDir.path, 'picker'));
|
||||
final localPaths = <String>[];
|
||||
final mimeTypes = <String>[];
|
||||
for (final item in items) {
|
||||
// Each item gets its own subfolder (keyed by id, not smashed into
|
||||
// the filename) so two different items can share a plain file
|
||||
// name without colliding, while the file on disk - and therefore
|
||||
// the display name the caller sees via the content:// Uri
|
||||
// MainActivity.kt hands back - stays exactly `item.name`.
|
||||
final itemDir = Directory(p.join(pickDir.path, item.id));
|
||||
await itemDir.create(recursive: true);
|
||||
final localPath = p.join(itemDir.path, item.name);
|
||||
await service.downloadToFile(item.path, localPath);
|
||||
localPaths.add(localPath);
|
||||
mimeTypes.add(item.mimeType ?? 'application/octet-stream');
|
||||
}
|
||||
await PickIntentService.finishPick(localPaths, mimeTypes);
|
||||
_pickRequest = null;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
_isDownloadingForPick = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backs out of picking mode entirely, telling the caller nothing was
|
||||
/// picked and closing the picker Activity.
|
||||
Future<void> cancelPick() async {
|
||||
if (_pickRequest == null) return;
|
||||
_pickRequest = null;
|
||||
notifyListeners();
|
||||
await PickIntentService.cancelPick();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/nextcloud_item.dart';
|
||||
import 'session_controller.dart';
|
||||
|
||||
/// The Recent Files tab. Kept separate from Files' own loading/error
|
||||
/// state, likewise.
|
||||
class RecentController extends ChangeNotifier {
|
||||
final SessionController session;
|
||||
|
||||
List<NextcloudItem> _items = [];
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
RecentController(this.session) {
|
||||
session.addAccountClearedListener(_onAccountCleared);
|
||||
session.addAccountActivatedListener(_onAccountActivated);
|
||||
}
|
||||
|
||||
List<NextcloudItem> get items => _items;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
void _onAccountCleared() {
|
||||
_items = [];
|
||||
_isLoading = false;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _onAccountActivated() {
|
||||
unawaited(fetchAll());
|
||||
}
|
||||
|
||||
Future<void> fetchAll() async {
|
||||
final service = session.service;
|
||||
if (!session.isLoggedIn || service == null) return;
|
||||
final gen = session.sessionGeneration;
|
||||
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final recent = await service.fetchRecentFiles();
|
||||
if (gen != session.sessionGeneration) return;
|
||||
_items = recent;
|
||||
} catch (e) {
|
||||
if (gen != session.sessionGeneration) return;
|
||||
debugPrint('[RecentController] Error fetching recent files: $e');
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
} finally {
|
||||
if (gen == session.sessionGeneration) {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||