Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.
|
||||||
@@ -13,20 +13,34 @@ lib/
|
|||||||
# ItemOperations, a plain cross-domain coordinator
|
# ItemOperations, a plain cross-domain coordinator
|
||||||
services/ # network/IO: NextcloudService, LoginFlowService,
|
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||||
# AccountStore, AppLockService
|
# 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,
|
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||||
# SharesView, RecentView, ActivityView, SearchView,
|
# SharesView, RecentView, ActivityView, SearchView,
|
||||||
# AccountView, LoginView, FileViewerScreen,
|
# AccountView, LoginView, FileViewerScreen,
|
||||||
# ShareUploadView, LockScreenView)
|
# ShareUploadView, LockScreenView)
|
||||||
widgets/ # reusable pieces shared across views
|
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
|
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
|
`views/` files are screens routed to directly (a tab, or pushed via
|
||||||
`Navigator`). `widgets/` files are building blocks used by more than one
|
`Navigator`). `widgets/` files are building blocks used by more than one
|
||||||
view (or complex enough to warrant their own file) — nothing in `widgets/`
|
view (or complex enough to warrant their own file) — nothing in `widgets/`
|
||||||
owns app state itself; it reads whichever controller(s) it needs via
|
owns app state itself; it reads whichever controller(s) it needs via
|
||||||
`context.watch`/`context.read`.
|
`context.watch`/`context.read`. Most tabs, the app shell, and Settings are
|
||||||
|
now built from `widgets/noo/` rather than raw Material widgets - see
|
||||||
|
`styling.md`'s "Noo design-system components" for the token/component
|
||||||
|
catalog and the current rebuild status.
|
||||||
|
|
||||||
## State management
|
## State management
|
||||||
|
|
||||||
@@ -60,7 +74,13 @@ new provider instance):
|
|||||||
(grid/list, hidden files, storage scope, sort field/order, per-folder
|
(grid/list, hidden files, storage scope, sort field/order, per-folder
|
||||||
sort map), and the shared `applyCommonFilters`/`applyFilesDisplayPrefs`
|
sort map), and the shared `applyCommonFilters`/`applyFilesDisplayPrefs`
|
||||||
helpers Photos/Favorites reuse rather than duplicating the same
|
helpers Photos/Favorites reuse rather than duplicating the same
|
||||||
filter/sort logic.
|
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) /
|
- [`PhotosController`](../../lib/providers/photos_controller.dart) /
|
||||||
[`FavoritesController`](../../lib/providers/favorites_controller.dart) —
|
[`FavoritesController`](../../lib/providers/favorites_controller.dart) —
|
||||||
each depends on `FilesController` for the shared storage-scope toggle and
|
each depends on `FilesController` for the shared storage-scope toggle and
|
||||||
@@ -78,10 +98,48 @@ new provider instance):
|
|||||||
— device-sync settings and live status (`syncStatusFor(item)`,
|
— device-sync settings and live status (`syncStatusFor(item)`,
|
||||||
`syncHeaderStatus`, conflicts), subscribed to `SyncService`'s status
|
`syncHeaderStatus`, conflicts), subscribed to `SyncService`'s status
|
||||||
stream directly rather than living inside the same object as the item
|
stream directly rather than living inside the same object as the item
|
||||||
lists it decorates.
|
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
|
- [`PickController`](../../lib/providers/pick_controller.dart) — "being
|
||||||
picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/
|
picked by another app" state (`pickRequest`/`isPicking`/`confirmPick`/
|
||||||
`itemMatchesPickFilter`) — see `server.md`.
|
`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
|
- [`ItemOperations`](../../lib/providers/item_operations.dart) — not a
|
||||||
`ChangeNotifier`; a plain, `const`-constructible class holding direct
|
`ChangeNotifier`; a plain, `const`-constructible class holding direct
|
||||||
references to `SessionController`/`FilesController`/`PhotosController`/
|
references to `SessionController`/`FilesController`/`PhotosController`/
|
||||||
@@ -95,6 +153,42 @@ new provider instance):
|
|||||||
(shares/activity/versions/restore) — stateless, so they don't need a
|
(shares/activity/versions/restore) — stateless, so they don't need a
|
||||||
controller of their own.
|
controller of their own.
|
||||||
|
|
||||||
|
**Guardrail - the "empty list on first load" failure class.** This bug has
|
||||||
|
recurred more than once, always the same shape: a tab's list is empty after
|
||||||
|
login even though the account is fine, because the controller behind it
|
||||||
|
never ran its initial fetch. The cause is a race, not a network bug:
|
||||||
|
`addAccountActivatedListener`/`addAccountReadyListener` fire *once*, at the
|
||||||
|
moment login is verified/ready, to whichever listeners are registered at
|
||||||
|
that exact instant - and most per-domain controllers (`FilesController`,
|
||||||
|
`PhotosController`, `FavoritesController`, `TrashController`,
|
||||||
|
`SharesController`, `RecentController`) are lazy `ChangeNotifierProvider`s,
|
||||||
|
only constructed (and so only registering their listener) whenever
|
||||||
|
something first reads them. If that first read happens to land *after*
|
||||||
|
the one-shot event already fired - e.g. `ConnectivityController`
|
||||||
|
misreporting offline for its first couple of seconds after a cold Android
|
||||||
|
start (see its own doc comment) collapses `main.dart`'s bottom nav to just
|
||||||
|
the Offline tab, delaying construction of every other tab's controller
|
||||||
|
until connectivity corrects itself and login has already finished
|
||||||
|
verifying - that controller's listener registers too late and its initial
|
||||||
|
fetch simply never happens. `SessionController.addAccountActivatedListener`/
|
||||||
|
`addAccountReadyListener` now close this at the root: registering either
|
||||||
|
one calls back **immediately** if the account is already in the state
|
||||||
|
being subscribed to, not just on the next fresh event, so a late-registering
|
||||||
|
controller always gets its initial fetch regardless of when its lazy
|
||||||
|
`Provider` happens to be built. Some views (`FilesView`/`PhotosView`, not
|
||||||
|
`FavoritesView` - see `FavoritesController`'s own doc comment) additionally
|
||||||
|
carry a build-time fallback (`_requestedInitialLoad` et al.: reload if
|
||||||
|
items are empty and not loading, once, after first build) predating this
|
||||||
|
fix; they're now redundant but harmless, and not worth touching for
|
||||||
|
cleanup alone. **Guardrail for new code:** any new network-fetching
|
||||||
|
controller that needs a one-time fetch on login must register through
|
||||||
|
`addAccountActivatedListener`/`addAccountReadyListener` in its constructor
|
||||||
|
and rely on their catch-up behavior - don't reach for a per-view
|
||||||
|
build-time "fetch if empty" fallback as the primary mechanism, since that
|
||||||
|
pattern is exactly what let this bug keep recurring silently (three
|
||||||
|
near-duplicate, slightly-diverging implementations, none of them fixing
|
||||||
|
the actual race).
|
||||||
|
|
||||||
`sessionGeneration` (on `SessionController`, read by every other
|
`sessionGeneration` (on `SessionController`, read by every other
|
||||||
controller) is incremented on every account switch so an in-flight fetch
|
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
|
from the account just left can recognize it's stale and discard its result
|
||||||
@@ -132,15 +226,18 @@ directly, which call `NextcloudService`/`LoginFlowService`/`AccountStore`/
|
|||||||
- else `session.needsUnlock` → `LockScreenView` (login lock — see above)
|
- else `session.needsUnlock` → `LockScreenView` (login lock — see above)
|
||||||
- else `MainShellView`
|
- else `MainShellView`
|
||||||
|
|
||||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 7 tabs — Files,
|
`MainShellView` is an `IndexedStack` over up to 8 tabs — Files, Photos,
|
||||||
Photos, Favorites, Activity, Trash, Shares, Recent — user-configurable
|
Favorites, Activity, Trash, Shares, Recent, Offline — user-configurable
|
||||||
(order, visibility up to `maxVisibleTabs`, default tab) via
|
(order, visibility up to `maxVisibleTabs`, default tab) via
|
||||||
`AppTab`/`SettingsController` and rendered through `buildAppTabView`
|
`AppTab`/`SettingsController` and rendered through `buildAppTabView`
|
||||||
(`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the
|
(`widgets/app_tab_view_builder.dart`). `maxVisibleTabs` (5) is less than the
|
||||||
total tab count, and `SettingsController._enforceMaxVisibleTabs` already
|
total tab count, and `SettingsController._enforceMaxVisibleTabs` already
|
||||||
auto-hides overflow on load (fresh install, or - as when Favorites was
|
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
|
added - an existing saved tab order from before a new tab existed), so
|
||||||
adding a tab to the `AppTab` enum needs no extra migration.
|
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
|
Each tab keeps its own `ScrollController` (survives tab switches via
|
||||||
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
||||||
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
||||||
@@ -159,38 +256,111 @@ another app" story) that feeds `PickController.pickRequest`; while
|
|||||||
`isPicking`, the visible tab list is overridden to just Files and Photos
|
`isPicking`, the visible tab list is overridden to just Files and Photos
|
||||||
regardless of the user's own hidden/reordered tab settings, since those
|
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
|
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.
|
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
|
`MainShellView` also fires a one-time notification-permission prompt on
|
||||||
its first mount (`_maybeRequestNotificationPermission`, gated by a plain
|
its first mount (`_maybeRequestNotificationPermission`, gated by a plain
|
||||||
`shared_preferences` flag so it only ever asks once, not on every
|
`shared_preferences` flag so it only ever asks once, not on every
|
||||||
launch): a plain-language `AlertDialog` explaining why Noo wants it
|
launch): a plain-language dialog (`showNooDialog`, two `NooButton`s)
|
||||||
(upload/download progress notifications - see `ShareUploadService.kt`/
|
explaining why Noo wants it (upload/download progress notifications - see
|
||||||
`DownloadService.kt`) before the OS's own `permission_handler`-driven
|
`ShareUploadService.kt`/`DownloadService.kt`) before the OS's own
|
||||||
`Permission.notification.request()`, since the bare system prompt gives
|
`permission_handler`-driven `Permission.notification.request()`, since the
|
||||||
no context on its own.
|
bare system prompt gives no context on its own.
|
||||||
|
|
||||||
`FloatingBottomNavBar` (`widgets/floating_bottom_bar.dart`) wraps its
|
`MainShellView` builds its chrome from the Noo nav kit
|
||||||
pill's item row in a horizontal `SingleChildScrollView` rather than a
|
(`widgets/noo/nav/`) and switches between two layouts on
|
||||||
plain `Row`, so a wide selected-item label plus several icon-only tabs
|
`NooLayout.isDesktop`:
|
||||||
scrolls instead of overflowing on narrower screens.
|
|
||||||
|
|
||||||
All six tabs, plus `ShareUploadView` (the share-to-upload destination
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
picker, pushed rather than a tab - see below), share
|
for *every* tab (previously only Files had shell-level top chrome, with
|
||||||
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
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
|
`CustomScrollView` with a pull-down "sync status" header (Google
|
||||||
Photos-style) and a classic Material refresh spinner shown during a
|
Photos-style) and a classic Material refresh spinner shown during a
|
||||||
pull-triggered sync. `ShareUploadView` mirrors the Files tab's own
|
pull-triggered sync. Now that every tab is rebuilt on Noo, each renders its
|
||||||
controls-row/breadcrumbs sticky header almost exactly, so arriving via
|
own content directly in a plain `RefreshIndicator` + `CustomScrollView`
|
||||||
another app's "Share to..." sheet still lands on the same top chrome
|
instead (`ColoredBox(colors.bg)` background, no shared header widget);
|
||||||
instead of a plain `AppBar` - its `actions` are `[ProfileAvatarButton()]`
|
device-sync status shows per-row (`NooFileRow`/`NooFileTableRow`'s
|
||||||
only (no `MoreTabsButton`: there's nowhere useful for it to go mid-upload,
|
`NooStatusIcon`s) or in the Offline tab's `NooSummaryCard`, not a shared
|
||||||
since jumping to another tab would abandon the destination picker);
|
pinned chip. `SyncedHeaderScaffold` is now unused - the last two screens on
|
||||||
backing out is the system back gesture/button, not a bespoke close icon in
|
it, `ShareUploadView` (the share-to-upload destination picker, pushed
|
||||||
the app bar. Its bottom action - "Upload to
|
rather than a tab - see below) and `MoveCopyDestinationPicker`, are
|
||||||
{folder}" - and the uploading-file-name summary above it (single line,
|
rebuilt too: both are a `Scaffold` with a `NooTopBar`+`NooTopBarBack`
|
||||||
auto-scrolling via `MarqueeTitle` if it doesn't fit) live together in one
|
(mobile) / `NooToolbar` (desktop) top bar, a noo-styled
|
||||||
rounded-top, elevated `Material` bar as `bottomNavigationBar`, reading as a
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
|
||||||
sheet peeking up from the bottom edge rather than a plain flat bar.
|
(`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
|
[`MarqueeTitle`](../../lib/widgets/marquee_title.dart) (`package:marquee`) is
|
||||||
shared with `FileViewerScreen`'s title - falls back to a plain ellipsized
|
shared with `FileViewerScreen`'s title - falls back to a plain ellipsized
|
||||||
`Text` when the content already fits, so short text never marquees.
|
`Text` when the content already fits, so short text never marquees.
|
||||||
@@ -210,16 +380,32 @@ no prior browsing session to preserve. The Move/Copy picker is pushed
|
|||||||
*while the user is actively browsing a specific Files-tab folder*, so
|
*while the user is actively browsing a specific Files-tab folder*, so
|
||||||
reusing the shared state would strand that folder's `pathStack` under it;
|
reusing the shared state would strand that folder's `pathStack` under it;
|
||||||
its local state means popping back always lands the user exactly where
|
its local state means popping back always lands the user exactly where
|
||||||
they were, untouched.
|
they were, untouched. Unlike `ShareUploadView`, it keeps `MoreTabsButton`
|
||||||
`SearchView`, `AccountView` (Settings), the file-details sheet, and the
|
in its top bar: that button pushes a hidden tab as its own stacked screen
|
||||||
share sheet are pushed on top via
|
rather than jumping the shell there, so it doesn't abandon this picker the
|
||||||
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than
|
way jumping tabs would. Its bottom bar shows a "Moving/Copying N item(s)"
|
||||||
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on
|
summary line (replaced by a danger-colored warning when the current folder
|
||||||
tap and cycles between saved accounts on a vertical swipe.
|
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) pass their selection
|
Files and Photos (the two tabs with multi-select) render their selection
|
||||||
toolbar into `SyncedHeaderScaffold`'s `selectionBar` param rather than
|
bar as a `pinned: true` sliver at the top of their own `CustomScrollView`,
|
||||||
rendering it as a second sliver app bar inside their own content: while
|
swapped in for the controls row/type-chips row while `_isSelecting` -
|
||||||
non-null, it fully takes over the pinned top bar in place of the
|
selecting reads as replacing that row in place, not adding a strip
|
||||||
sync-status chip/`actions`/pull-to-reveal quota panel, so selecting reads
|
beneath it. (An earlier version of this routed the selection bar through
|
||||||
as replacing the whole top chrome rather than adding a strip beneath it.
|
`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>
|
||||||
@@ -73,6 +73,15 @@ rather than needing a rewrite for multi-account support.
|
|||||||
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
|
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
|
||||||
file exist because WebDAV responses use RFC 1123 dates and either bare
|
file exist because WebDAV responses use RFC 1123 dates and either bare
|
||||||
paths or full URLs for `href`; reuse them rather than re-deriving.
|
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,
|
- **Everything else** (shares, activity, trash, favorites, quota, user info,
|
||||||
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
|
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
|
||||||
in, with the `OCS-APIRequest: true` header required on every OCS call.
|
in, with the `OCS-APIRequest: true` header required on every OCS call.
|
||||||
@@ -197,13 +206,26 @@ rather than needing a rewrite for multi-account support.
|
|||||||
Dio from a separate process lifecycle - `UploadService.startUpload` passes
|
Dio from a separate process lifecycle - `UploadService.startUpload` passes
|
||||||
everything the Kotlin side needs (the pre-built `Authorization` header
|
everything the Kotlin side needs (the pre-built `Authorization` header
|
||||||
from `NextcloudService.authHeaders`, not the raw password) as Intent
|
from `NextcloudService.authHeaders`, not the raw password) as Intent
|
||||||
extras, a one-way handoff with no channel back to Dart afterward. Keep
|
extras, a one-way handoff for the start of the upload - the Kotlin side
|
||||||
the two upload implementations in sync manually if upload semantics
|
never asks Dart anything mid-upload, since the app may not even be
|
||||||
change. Progress/cancellation is entirely notification-driven (one
|
running by then. Keep the two upload implementations in sync manually if
|
||||||
ongoing, updatable notification for the whole batch; its Cancel action
|
upload semantics change. Progress/cancellation is entirely notification-
|
||||||
re-delivers an Intent to the same running service instance, which an
|
driven (one ongoing, updatable notification for the whole batch; its
|
||||||
`AtomicBoolean` the copy/upload loops poll) - there's no plumbing back to
|
Cancel action re-delivers an Intent to the same running service instance,
|
||||||
the Dart UI, by design, since the app may not even be running.
|
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)
|
## Being picked by other apps (photo/file picker)
|
||||||
|
|
||||||
@@ -274,9 +296,13 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
`oc:fileid` - stable across renames/moves, unlike `path`) to decide, per
|
`oc:fileid` - stable across renames/moves, unlike `path`) to decide, per
|
||||||
file: download (new, or server `etag` changed), upload (local file's
|
file: download (new, or server `etag` changed), upload (local file's
|
||||||
mtime/size changed and the server didn't), delete locally (missing
|
mtime/size changed and the server didn't), delete locally (missing
|
||||||
server-side, unchanged locally), respect a local deletion (file's gone
|
server-side, unchanged locally), re-download a file whose local copy is
|
||||||
and the server didn't change either - don't recreate it), or flag a
|
missing even though state says it was mirrored (stale state from an
|
||||||
**conflict** (both changed since the last recorded state).
|
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)
|
- [`SyncWorker.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncWorker.kt)
|
||||||
(`CoroutineWorker`) is both the periodic job and the one-off "Sync now":
|
(`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
|
for each configured path, `propfindSelf`s it first to check whether it's
|
||||||
@@ -286,7 +312,16 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
came from a walk or a single lookup, so single-file sync needed no engine
|
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
|
changes, just this one branch) - applies `diffFolder`'s decisions, then
|
||||||
posts a summary notification (files updated/uploaded/removed) and, for
|
posts a summary notification (files updated/uploaded/removed) and, for
|
||||||
any conflicts, one notification per file with two actions.
|
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** -
|
- **Live status reaches Dart via a push channel, not polling** -
|
||||||
[`SyncStatusBus.kt`](../../android/app/src/main/kotlin/dev/ayushya/noo/SyncStatusBus.kt)
|
[`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
|
is a plain in-process pub/sub (no IPC needed - the workers and
|
||||||
@@ -299,8 +334,19 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
from `SyncEngine`'s durable per-account state map
|
from `SyncEngine`'s durable per-account state map
|
||||||
(`SyncEngine.loadState(accountId).keys`) each time a snapshot is built,
|
(`SyncEngine.loadState(accountId).keys`) each time a snapshot is built,
|
||||||
so there's one source of truth for "synced" instead of two that could
|
so there's one source of truth for "synced" instead of two that could
|
||||||
drift. `SyncService.getStatus()` (one-shot, seeds `SyncStatusController`
|
drift - which is also why `SyncWorker.doWork()` saves state *before*
|
||||||
right after login/account-switch) and `SyncService.statusStream` (live)
|
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`
|
both return the same snapshot shape. `SyncStatusController.syncHeaderStatus`
|
||||||
(off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent
|
(off/syncing/done/alert - drives `SyncedHeaderScaffold`'s persistent
|
||||||
chip/panel, replacing what used to be the WebDAV-refresh-loading
|
chip/panel, replacing what used to be the WebDAV-refresh-loading
|
||||||
@@ -333,6 +379,37 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
[`ConflictResolveWorker`](../../android/app/src/main/kotlin/dev/ayushya/noo/ConflictResolveWorker.kt)
|
[`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
|
to actually push the local copy up or pull the server copy down and
|
||||||
refresh that file's recorded state.
|
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
|
- `MainActivity.kt`'s `dev.ayushya.noo/sync_service` channel
|
||||||
(`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge
|
(`reschedule`/`cancel`/`syncNow`/`removeLocalSync`) is the only bridge
|
||||||
from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are
|
from Dart: a periodic `WorkRequest`'s input `Data` and `Constraints` are
|
||||||
@@ -341,8 +418,46 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
not updating in place. [`SyncService`](../../lib/services/sync_service.dart)
|
not updating in place. [`SyncService`](../../lib/services/sync_service.dart)
|
||||||
(Dart) wraps this - `SyncStatusController` calls `SyncService.reschedule`
|
(Dart) wraps this - `SyncStatusController` calls `SyncService.reschedule`
|
||||||
after every successful login/account switch and every synced-folder/
|
after every successful login/account switch and every synced-folder/
|
||||||
`syncOnCellular` change, and `SyncService.cancel` on logout/last-account-
|
cache-rule/`syncOnCellular` change. `reschedule` covers **every saved
|
||||||
removed. The network constraint is `NetworkType.UNMETERED` by default
|
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
|
(`!syncOnCellular`, Wi-Fi only) or `NetworkType.CONNECTED` if the user's
|
||||||
opted into cellular sync. Turning sync off for a path
|
opted into cellular sync. Turning sync off for a path
|
||||||
(`SyncStatusController.removeSyncedPath`) also calls
|
(`SyncStatusController.removeSyncedPath`) also calls
|
||||||
@@ -362,20 +477,89 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
tracks which of those paths are folders vs individual files for the sync
|
tracks which of those paths are folders vs individual files for the sync
|
||||||
header's folder/item counts, defaulting missing entries to folder (the
|
header's folder/item counts, defaulting missing entries to folder (the
|
||||||
common case, and what any path added before this map existed will look
|
common case, and what any path added before this map existed will look
|
||||||
like). `syncEverything` (`ui_sync_everything`, per account) works the
|
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
|
same way - when on, `SyncService` sends `['/']` as the path list instead
|
||||||
of `syncedPaths`, mirroring the whole account rather than requiring
|
of `syncedPaths`, mirroring the whole account rather than requiring
|
||||||
per-item opt-in. `syncOnCellular` is a plain global pref. All three are
|
per-item opt-in. `syncOnCellular` is a plain global pref. `syncEverything`
|
||||||
managed from
|
and `syncOnCellular` are managed from Settings → Device Sync (a "Sync
|
||||||
Settings → Device Sync (a "Sync everything" switch, the path list with
|
everything" switch, the cellular toggle, a manual "Sync now", and a
|
||||||
remove buttons - hidden while "Sync everything" is on - the cellular
|
"View offline files" row that pushes the Offline tab - see below); the
|
||||||
toggle, and a manual "Sync now"); individual files or folders are
|
configured path list itself, with its remove buttons, lives on the
|
||||||
additionally toggled from Files' selection toolbar ("Sync to device",
|
Offline tab now, not Settings. Individual files or folders are toggled
|
||||||
works over the whole selection at once - either item type, folders or
|
from Files' selection toolbar ("Sync to device", works over the whole
|
||||||
files - not just a single item; the action reads as "stop syncing" only
|
selection at once - either item type, folders or files - not just a
|
||||||
once every selected item is already synced, otherwise it syncs whichever
|
single item; the action reads as "stop syncing" only once every selected
|
||||||
ones aren't yet, and either direction ends with a confirmation
|
item is already synced, otherwise it syncs whichever ones aren't yet,
|
||||||
SnackBar).
|
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,
|
- **`android/app/proguard-rules.pro` exists specifically for this feature,
|
||||||
and keeps `androidx.work.**` wholesale rather than naming individual
|
and keeps `androidx.work.**` wholesale rather than naming individual
|
||||||
classes.** Flutter's own Gradle plugin auto-enables R8 minification for
|
classes.** Flutter's own Gradle plugin auto-enables R8 minification for
|
||||||
@@ -419,6 +603,98 @@ the whole engine is plain Kotlin using Android's WorkManager directly.
|
|||||||
selected file is already synced - a mixed selection still goes through
|
selected file is already synced - a mixed selection still goes through
|
||||||
the normal `DownloadService` batch) both check it first.
|
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
|
## Multi-account storage & session persistence
|
||||||
|
|
||||||
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
||||||
|
|||||||
@@ -62,17 +62,60 @@ class/method already makes obvious.
|
|||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- Widget tests must mock platform channels that the app touches on startup
|
- Widget tests must mock platform channels that the app touches on startup
|
||||||
— `SharedPreferences.setMockInitialValues({})` and a mock
|
— `SharedPreferences.setMockInitialValues({})`, a mock
|
||||||
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler —
|
`MethodChannel('plugins.it_nomads.com/flutter_secure_storage')` handler,
|
||||||
and disable Google Fonts network fetching
|
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
|
(`GoogleFonts.config.allowRuntimeFetching = false`) in `setUpAll`. Without
|
||||||
these, `SessionController`'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
|
sandbox (no plugin implementation is registered, so the read future just
|
||||||
never completes) and the app stays on `_SplashView`'s indeterminate
|
never completes) and the app stays on `_SplashView`'s indeterminate
|
||||||
spinner, which makes `pumpAndSettle()` hang until its own timeout instead
|
spinner, which makes `pumpAndSettle()` hang until its own timeout instead
|
||||||
of failing fast. See `test/widget_test.dart` for the reference setup.
|
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`.
|
- Run with `flutter test`.
|
||||||
|
|
||||||
|
## Play Store screenshots
|
||||||
|
|
||||||
|
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
|
||||||
|
listing screenshots **without any real data**: it runs the real, unmodified app
|
||||||
|
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
|
||||||
|
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
|
||||||
|
it with `integration_test/store_screenshots_test.dart` (host-side driver:
|
||||||
|
`test_driver/integration_test.dart`, which saves `adb screencap` images).
|
||||||
|
Output lands in `store_listing/screenshots/` (gitignored) after
|
||||||
|
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
|
||||||
|
limits (each side 320-3840 px, long side at most 2x the short side - a stock
|
||||||
|
1080x2400 phone screen is 2.22:1 and would be rejected).
|
||||||
|
|
||||||
|
- The test logs in by writing the demo user's account/app password into
|
||||||
|
storage via `AccountStore` before calling `main()`, so it skips the browser
|
||||||
|
login flow; it also fixes the theme (light, non-dynamic colour), the visible
|
||||||
|
tabs and the first-run notification prompt so shots are reproducible.
|
||||||
|
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
|
||||||
|
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
|
||||||
|
`FilesView`s, and the fake content's names (defined in
|
||||||
|
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
|
||||||
|
- `flutter test` only runs `test/`, so this never runs as part of the normal
|
||||||
|
suite; `integration_test` is a dev-only dependency.
|
||||||
|
- Always review the images before uploading - see
|
||||||
|
`tool/demo_server/README.md` for the known places real data could appear.
|
||||||
|
|
||||||
## Local install/deploy
|
## Local install/deploy
|
||||||
|
|
||||||
Never use `flutter install` to push a build to a test device — it always
|
Never use `flutter install` to push a build to a test device — it always
|
||||||
@@ -95,15 +138,18 @@ 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
|
in this order: a local `android/key.properties` (gitignored — points at a
|
||||||
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
|
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
|
||||||
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
|
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
|
||||||
`_PASSWORD`, set by `.gitea/workflows/build.yml` from repo secrets), then
|
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
|
||||||
falls back to the debug key if neither is configured. As long as the same
|
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
|
||||||
dedicated release keystore backs both `key.properties` locally and the
|
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
|
||||||
Gitea secrets, local release builds and CI-built release APKs share one
|
the `.aab` Play Console wants), then falls back to the debug key if neither
|
||||||
signature, so `adb install -r` works cleanly either way. A local checkout
|
is configured. As long as the same dedicated release keystore backs both
|
||||||
with no `key.properties` set up falls back to the (per-machine, ungitted)
|
`key.properties` locally and the Gitea secrets, local release builds and
|
||||||
debug key, which won't match a CI-signed APK — installing one over the
|
CI-built release APKs share one signature, so `adb install -r` works
|
||||||
other still forces a full uninstall, since there's no way around Android's
|
cleanly either way. A local checkout with no `key.properties` set up falls
|
||||||
signature check from the tooling side.
|
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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Styling
|
# 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
|
## Theme
|
||||||
|
|
||||||
All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart)
|
All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart)
|
||||||
@@ -34,51 +50,238 @@ widgets. Key points:
|
|||||||
and no `scrollbarTheme` override (tried once, explicitly reverted). Don't
|
and no `scrollbarTheme` override (tried once, explicitly reverted). Don't
|
||||||
reintroduce one without being asked.
|
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`, `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` |
|
||||||
|
| `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
|
## 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)
|
- [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart)
|
||||||
— the icon/color/thumbnail treatment for a file or folder, shared by any
|
— `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
|
||||||
screen that lists `NextcloudItem`s the way the Files tab does (currently
|
rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
|
||||||
`files_view.dart` and `share_upload_view.dart`'s destination picker).
|
`NooFileTile`/`NooFileRow`/`NooFileTableRow`/`NooGridCard`'s `thumbnail`
|
||||||
Extend this rather than re-deriving per-type icons/colors at a new call
|
slot. `ShareUploadView`/`MoveCopyDestinationPicker` don't use any of the
|
||||||
site.
|
three any more - both destination pickers now list folders only (see
|
||||||
- [`DetailsHeader`](../../lib/widgets/details/details_sheet.dart) — the
|
below), and a folder never gets a real thumbnail (only image/video do),
|
||||||
icon-box/name/meta row every per-item bottom sheet opens on
|
so a plain `NooFileTile(kind: NooFileKind.folder)` covers every row.
|
||||||
(`DetailsSheet`, the media viewer's collapsed peek state, and
|
- `DetailsSheet`/`ShareSheet` (`lib/widgets/details/details_sheet.dart`,
|
||||||
`ShareSheet`). Takes a `padding` override for callers whose own scroll
|
`lib/widgets/share_sheet.dart`) are rebuilt: `showNooSheet`/`showNooDialog`
|
||||||
view already applies horizontal insets (`ShareSheet`'s `ListView`), so it
|
per `NooLayout.isDesktop`, with the header built from `detailsFileTile`
|
||||||
doesn't get doubled up. Reuse this instead of a bare title `Text` for any
|
(a `NooFileTile` keyed by `NooFileKind.from`) and `detailsMetaLine` -
|
||||||
new per-item sheet - a plain title reads as under-designed next to the
|
shared top-level helpers in `details_sheet.dart` so both sheets open on
|
||||||
other sheets (a real instance: `ShareSheet` used to be just that).
|
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) —
|
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||||||
the blurred/translucent pill background shared by all floating chrome
|
the blurred/translucent pill background for the media viewer's top/bottom
|
||||||
(bottom nav bar, media-viewer top/bottom bars and video transport
|
bars and video transport controls (its other former user, the floating
|
||||||
controls). Reuse this for any new floating overlay instead of building a
|
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||||
new blur/shadow combo.
|
overlay instead of building a new blur/shadow combo.
|
||||||
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
|
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||||
main tab bar; opacity/blur are user-adjustable settings
|
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||||
(`SettingsController.bottomBarOpacity`/`bottomBarBlur`), not constants —
|
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||||
pull new adjustable visual knobs from `SettingsController` the same way
|
are gone: the bottom bar is now the flat, non-blurred `NooBottomBar` (no
|
||||||
rather than hardcoding them.
|
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. `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) —
|
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||||
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
|
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
||||||
`architecture.md`); also where the pull-to-refresh gesture thresholds and
|
chip and pull-to-refresh gesture/spinner. Every screen (including
|
||||||
the classic Material refresh spinner live. Its persistent compact chip
|
`ShareUploadView`/`MoveCopyDestinationPicker`, its last two users) has
|
||||||
(icon + "Sync off"/"Syncing…"/"Synced"/"Sync issue") reflects device-sync
|
dropped it for a plain `RefreshIndicator` + `CustomScrollView`
|
||||||
status (`SyncStatusController.syncHeaderStatus`), not the WebDAV-refresh
|
(device-sync status now shows per-row via `NooStatusIcon`/the Offline
|
||||||
loading state the pull gesture itself triggers - that has its own,
|
`NooSummaryCard`, not a shared header chip), so the `SyncedHeaderScaffold`
|
||||||
separate floating spinner bubble, so nothing was lost by handing the
|
class itself is now dead code - kept only because the same file's
|
||||||
persistent text/icon over. The expanded panel's headline is a separate,
|
top-level `formatBytes` helper is still widely used
|
||||||
more detailed string (`_syncSummary` in `synced_header_scaffold.dart`) -
|
(`files_view.dart`/`favorites_view.dart`/`shell_common.dart`/
|
||||||
actual folder/item counts ("2 folders & 5 items synced") rather than
|
`widgets/details/*`).
|
||||||
just repeating the chip's generic label, which would otherwise read
|
- [`SegmentedIconGroup`/`ToggleIconButton`](../../lib/widgets/segmented_icon_toggle.dart)
|
||||||
"Synced" even when a folder's just been added and nothing's downloaded
|
and [`SortMenuButton`](../../lib/widgets/sort_menu_button.dart) — the
|
||||||
yet.
|
Material sort/filter-chip pieces `ShareUploadView`/
|
||||||
- [`SyncStatusBadge`](../../lib/widgets/sync_status_badge.dart) — the small
|
`MoveCopyDestinationPicker` used to mirror Files' old controls row with.
|
||||||
corner badge over a thumbnail showing per-item device-sync status
|
Both destination pickers dropped that whole row (they only ever browse
|
||||||
(`cloud_done`/`sync`, nothing for not-synced/conflict); used in Files'
|
folders, so sort/hidden/scope/type-filter/grid controls don't apply), so
|
||||||
list and grid tiles today. Reuse this rather than a new ad hoc badge if
|
these two files are now dead code too - `Breadcrumbs` is the only shared
|
||||||
another view starts showing sync status per item.
|
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)
|
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||||
for the video player's seek bar, plus a perpetually-animated
|
for the video player's seek bar, plus a perpetually-animated
|
||||||
@@ -116,4 +319,8 @@ widgets. Key points:
|
|||||||
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
|
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
|
||||||
reads correctly in both light and dark mode. Never the full-color
|
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
|
`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.
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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: 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"
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
@@ -44,3 +44,7 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Play Store screenshot tooling (tool/screenshots.sh)
|
||||||
|
/store_listing/screenshots/
|
||||||
|
/tool/demo_server/.venv/
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ relevant file(s) before working in that area rather than loading all of them:
|
|||||||
conventions, fonts, reusable chrome widgets
|
conventions, fonts, reusable chrome widgets
|
||||||
- [`standards.md`](.claude/context/standards.md) — code style, comment
|
- [`standards.md`](.claude/context/standards.md) — code style, comment
|
||||||
conventions, linting, testing
|
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
|
## Common commands
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<!-- For local_auth's biometric app-lock (Settings > Security). -->
|
<!-- For local_auth's biometric app-lock (Settings > Security). -->
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
|
||||||
|
<!-- 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"
|
<!-- For ShareUploadService/DownloadService, which keep a "Share to Noo"
|
||||||
upload/download going (and cancellable from its notification) even
|
upload/download going (and cancellable from its notification) even
|
||||||
if the app is fully closed - see each service's own doc comment. -->
|
if the app is fully closed - see each service's own doc comment. -->
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.work.WorkManager
|
|||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -62,6 +63,11 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
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 accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure()
|
||||||
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: 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 username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure()
|
||||||
@@ -84,7 +90,9 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
// Refresh the recorded state from the server's post-resolution
|
// Refresh the recorded state from the server's post-resolution
|
||||||
// etag, so this file isn't immediately re-flagged as a conflict on
|
// etag, so this file isn't immediately re-flagged as a conflict on
|
||||||
// the next sync pass.
|
// the next sync pass.
|
||||||
val fresh = SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath)
|
val fresh = runCatching {
|
||||||
|
SyncEngine.propfindSelf(serverUrl, username, authHeader, remotePath)
|
||||||
|
}.getOrNull()
|
||||||
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
|
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
|
||||||
state[fileId] = SyncEngine.FileState(
|
state[fileId] = SyncEngine.FileState(
|
||||||
relPath = relPath,
|
relPath = relPath,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class DownloadService : Service() {
|
|||||||
const val EXTRA_USERNAME = "username"
|
const val EXTRA_USERNAME = "username"
|
||||||
const val EXTRA_AUTH_HEADER = "authHeader"
|
const val EXTRA_AUTH_HEADER = "authHeader"
|
||||||
|
|
||||||
private const val CHANNEL_ID = "file_downloads"
|
private const val CHANNEL_ID = "file_downloads_v2"
|
||||||
private const val NOTIFICATION_ID = 4301
|
private const val NOTIFICATION_ID = 4301
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +86,11 @@ class DownloadService : Service() {
|
|||||||
this,
|
this,
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"File downloads",
|
"File downloads",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
// Default (not low) importance so a finished download actually
|
||||||
"Progress for files downloaded from Noo",
|
// 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(
|
ServiceCompat.startForeground(
|
||||||
this,
|
this,
|
||||||
@@ -250,6 +253,7 @@ class DownloadService : Service() {
|
|||||||
.setContentTitle("Downloading from Noo")
|
.setContentTitle("Downloading from Noo")
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
|
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
|
private val newPickChannelName = "dev.ayushya.noo/pick_intent/new"
|
||||||
private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
|
private val syncServiceChannelName = "dev.ayushya.noo/sync_service"
|
||||||
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
|
private val syncStatusChannelName = "dev.ayushya.noo/sync_service/status"
|
||||||
|
private val uploadStatusChannelName = "dev.ayushya.noo/upload_service/status"
|
||||||
private val notificationPermissionRequestCode = 4202
|
private val notificationPermissionRequestCode = 4202
|
||||||
// Lazy, not a field initializer - `packageName` reads through the
|
// Lazy, not a field initializer - `packageName` reads through the
|
||||||
// Activity's base Context, which isn't attached yet while this class's
|
// Activity's base Context, which isn't attached yet while this class's
|
||||||
@@ -66,6 +67,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
private var newShareSink: EventChannel.EventSink? = null
|
private var newShareSink: EventChannel.EventSink? = null
|
||||||
private var newPickSink: EventChannel.EventSink? = null
|
private var newPickSink: EventChannel.EventSink? = null
|
||||||
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
|
private var syncStatusListener: ((SyncStatusBus.Status) -> Unit)? = null
|
||||||
|
private var uploadCompletedListener: ((UploadEventBus.Completed) -> Unit)? = null
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
@@ -132,9 +134,11 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"reschedule" -> rescheduleSyncWork(call, result)
|
"reschedule" -> rescheduleSyncWork(call, result)
|
||||||
"cancel" -> cancelSyncWork(result)
|
"cancel" -> cancelSyncWork(call, result)
|
||||||
"syncNow" -> syncNow(call, result)
|
"syncNow" -> syncNow(call, result)
|
||||||
"getSyncStatus" -> result.success(syncStatusMap(SyncStatusBus.snapshot()))
|
"getSyncStatus" -> result.success(
|
||||||
|
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
|
||||||
|
)
|
||||||
"resolveConflict" -> resolveConflict(call, result)
|
"resolveConflict" -> resolveConflict(call, result)
|
||||||
"removeLocalSync" -> removeLocalSync(call, result)
|
"removeLocalSync" -> removeLocalSync(call, result)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
@@ -155,18 +159,56 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
syncStatusListener = null
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun syncStatusMap(status: SyncStatusBus.Status): Map<String, Any?> {
|
/// [accountIdOverride] is the account Dart is asking about - needed
|
||||||
val syncedFileIds = status.accountId?.let {
|
/// 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()
|
SyncEngine.loadState(applicationContext, it).keys.toList()
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"accountId" to status.accountId,
|
"accountId" to accountId,
|
||||||
"syncing" to status.syncing,
|
"syncing" to (busMatches && status.syncing),
|
||||||
"syncingFileIds" to status.syncingFileIds.toList(),
|
"syncingFileIds" to if (busMatches) status.syncingFileIds.toList() else emptyList(),
|
||||||
"syncedFileIds" to syncedFileIds,
|
"syncedFileIds" to syncedFileIds,
|
||||||
"conflicts" to status.conflicts.map {
|
"missingRoots" to (accountId?.let {
|
||||||
|
SyncEngine.loadMissingRoots(applicationContext, it).toList()
|
||||||
|
} ?: emptyList()),
|
||||||
|
"conflicts" to (if (busMatches) status.conflicts else emptyList()).map {
|
||||||
mapOf(
|
mapOf(
|
||||||
"accountId" to it.accountId,
|
"accountId" to it.accountId,
|
||||||
"fileId" to it.fileId,
|
"fileId" to it.fileId,
|
||||||
@@ -346,7 +388,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
|
|
||||||
/// Shared arg-parsing for `reschedule`/`syncNow` - both need the same
|
/// Shared arg-parsing for `reschedule`/`syncNow` - both need the same
|
||||||
/// account/credentials/folder-list shape, just enqueue differently.
|
/// account/credentials/folder-list shape, just enqueue differently.
|
||||||
private fun syncWorkData(call: MethodCall): androidx.work.Data? {
|
private fun syncWorkData(call: MethodCall, force: Boolean = false): androidx.work.Data? {
|
||||||
val accountId = call.argument<String>("accountId")
|
val accountId = call.argument<String>("accountId")
|
||||||
val serverUrl = call.argument<String>("serverUrl")
|
val serverUrl = call.argument<String>("serverUrl")
|
||||||
val username = call.argument<String>("username")
|
val username = call.argument<String>("username")
|
||||||
@@ -363,6 +405,8 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
SyncWorker.KEY_USERNAME to username,
|
SyncWorker.KEY_USERNAME to username,
|
||||||
SyncWorker.KEY_AUTH_HEADER to authHeader,
|
SyncWorker.KEY_AUTH_HEADER to authHeader,
|
||||||
SyncWorker.KEY_FOLDERS to foldersJson,
|
SyncWorker.KEY_FOLDERS to foldersJson,
|
||||||
|
SyncWorker.KEY_FORCE to force,
|
||||||
|
SyncWorker.KEY_NOTIFY to (call.argument<Boolean>("notify") ?: true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,13 +416,26 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
/// periodic WorkRequest's input Data/constraints are fixed at enqueue
|
/// periodic WorkRequest's input Data/constraints are fixed at enqueue
|
||||||
/// time and can only be changed by cancelling and re-enqueueing.
|
/// time and can only be changed by cancelling and re-enqueueing.
|
||||||
private fun rescheduleSyncWork(call: MethodCall, result: MethodChannel.Result) {
|
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 data = syncWorkData(call)
|
||||||
val foldersJson = call.argument<String>("folders")
|
val foldersJson = call.argument<String>("folders")
|
||||||
val wifiOnly = call.argument<Boolean>("wifiOnly") ?: true
|
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)
|
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) {
|
if (data == null || foldersJson == null || JSONArray(foldersJson).length() == 0 ||
|
||||||
workManager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
|
intervalMinutes == null
|
||||||
|
) {
|
||||||
|
workManager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId))
|
||||||
result.success(null)
|
result.success(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -386,23 +443,33 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
val constraints = Constraints.Builder()
|
val constraints = Constraints.Builder()
|
||||||
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
||||||
.build()
|
.build()
|
||||||
// 1 hour is WorkManager's own practical floor for a "battery-
|
// Follows the Files Cache "refresh periodically" interval. Android
|
||||||
// friendly" cadence well above its hard 15-minute minimum; there's
|
// won't run periodic work more often than every 15 minutes, so
|
||||||
// no per-user interval setting for this in v1.
|
// shorter intervals are clamped up (the app's own in-foreground
|
||||||
val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
|
// timer covers the shorter cadence while it's open).
|
||||||
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||||
|
maxOf(intervalMinutes, 15).toLong(),
|
||||||
|
TimeUnit.MINUTES,
|
||||||
|
)
|
||||||
.setInputData(data)
|
.setInputData(data)
|
||||||
.setConstraints(constraints)
|
.setConstraints(constraints)
|
||||||
.build()
|
.build()
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
SyncWorker.UNIQUE_PERIODIC_NAME,
|
SyncWorker.periodicNameFor(accountId),
|
||||||
ExistingPeriodicWorkPolicy.UPDATE,
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun cancelSyncWork(result: MethodChannel.Result) {
|
/// Cancels one account's periodic job (e.g. when it's removed).
|
||||||
WorkManager.getInstance(this).cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME)
|
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)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,16 +494,34 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
/// One-off immediate run (Settings' "Sync now"), independent of the
|
/// One-off immediate run (Settings' "Sync now"), independent of the
|
||||||
/// periodic schedule.
|
/// periodic schedule.
|
||||||
private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
|
private fun syncNow(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val data = syncWorkData(call)
|
// `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) {
|
if (data == null) {
|
||||||
result.error("bad_args", "Missing required sync arguments", null)
|
result.error("bad_args", "Missing required sync arguments", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val request = OneTimeWorkRequestBuilder<SyncWorker>().setInputData(data).build()
|
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(
|
WorkManager.getInstance(this).enqueueUniqueWork(
|
||||||
SyncWorker.UNIQUE_ONE_OFF_NAME,
|
// Per account so switching accounts doesn't let one account's
|
||||||
ExistingWorkPolicy.REPLACE,
|
// run replace/keep-out another's.
|
||||||
request,
|
SyncWorker.oneOffNameFor(call.argument<String>("accountId")!!),
|
||||||
|
if (force) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
|
||||||
|
builder.build(),
|
||||||
)
|
)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,21 @@ object NooNotificationChannels {
|
|||||||
name: String,
|
name: String,
|
||||||
importance: Int,
|
importance: Int,
|
||||||
description: String,
|
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
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
legacyIds.forEach { manager.deleteNotificationChannel(it) }
|
||||||
val channel = NotificationChannel(id, name, importance).apply {
|
val channel = NotificationChannel(id, name, importance).apply {
|
||||||
this.description = description
|
this.description = description
|
||||||
|
if (importance <= NotificationManager.IMPORTANCE_LOW) {
|
||||||
|
setSound(null, null)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class ShareUploadService : Service() {
|
|||||||
const val EXTRA_AUTH_HEADER = "authHeader"
|
const val EXTRA_AUTH_HEADER = "authHeader"
|
||||||
const val EXTRA_REMOTE_FOLDER = "remoteFolder"
|
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 const val NOTIFICATION_ID = 4201
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,9 @@ class ShareUploadService : Service() {
|
|||||||
this,
|
this,
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"File uploads",
|
"File uploads",
|
||||||
NotificationManager.IMPORTANCE_LOW,
|
// 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",
|
"Progress for files shared to Noo",
|
||||||
)
|
)
|
||||||
ServiceCompat.startForeground(
|
ServiceCompat.startForeground(
|
||||||
@@ -183,6 +185,13 @@ class ShareUploadService : Service() {
|
|||||||
else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed"
|
else -> "Uploaded $succeeded of ${batch.files.size} files - $failed failed"
|
||||||
}
|
}
|
||||||
manager().notify(NOTIFICATION_ID, buildFinalNotification(finalText))
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
if (queue.pendingCount == 0) {
|
if (queue.pendingCount == 0) {
|
||||||
stopForeground(STOP_FOREGROUND_DETACH)
|
stopForeground(STOP_FOREGROUND_DETACH)
|
||||||
@@ -239,6 +248,7 @@ class ShareUploadService : Service() {
|
|||||||
.setContentTitle("Uploading to Noo")
|
.setContentTitle("Uploading to Noo")
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
|
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Cancel", cancelPendingIntent)
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -48,6 +49,27 @@ object SyncEngine {
|
|||||||
val localMTime: 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 {
|
sealed class SyncAction {
|
||||||
data class Download(val entry: RemoteEntry) : SyncAction()
|
data class Download(val entry: RemoteEntry) : SyncAction()
|
||||||
data class Upload(val relPath: String, val fileId: String) : SyncAction()
|
data class Upload(val relPath: String, val fileId: String) : SyncAction()
|
||||||
@@ -55,6 +77,18 @@ object SyncEngine {
|
|||||||
data class Conflict(val entry: RemoteEntry, val relPath: 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 TAG = "NooSync"
|
||||||
private const val STATE_PREFS = "noo_sync_state"
|
private const val STATE_PREFS = "noo_sync_state"
|
||||||
private val rfc1123 =
|
private val rfc1123 =
|
||||||
@@ -73,6 +107,108 @@ object SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun stateKey(accountId: String) = "state_$accountId"
|
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> {
|
fun loadState(context: Context, accountId: String): MutableMap<String, FileState> {
|
||||||
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -161,9 +297,10 @@ object SyncEngine {
|
|||||||
return try {
|
return try {
|
||||||
httpClient.newCall(request).execute().use { response ->
|
httpClient.newCall(request).execute().use { response ->
|
||||||
Log.d(TAG, "PROPFIND $url -> ${response.code}")
|
Log.d(TAG, "PROPFIND $url -> ${response.code}")
|
||||||
|
if (response.code == 404) return emptyList() // genuinely gone
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}")
|
Log.w(TAG, "PROPFIND $url failed: ${response.code} ${response.body?.string()}")
|
||||||
return emptyList()
|
throw RemoteUnavailableException("PROPFIND $url -> ${response.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val doc = DocumentBuilderFactory.newInstance()
|
val doc = DocumentBuilderFactory.newInstance()
|
||||||
@@ -173,9 +310,11 @@ object SyncEngine {
|
|||||||
|
|
||||||
parsePropfindResponse(doc, username, cleanPath, skipSelf)
|
parsePropfindResponse(doc, username, cleanPath, skipSelf)
|
||||||
}
|
}
|
||||||
|
} catch (e: RemoteUnavailableException) {
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "PROPFIND $url threw", e)
|
Log.e(TAG, "PROPFIND $url threw", e)
|
||||||
emptyList()
|
throw RemoteUnavailableException("PROPFIND $url threw", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,11 +462,16 @@ object SyncEngine {
|
|||||||
(localFile.lastModified() != prior.localMTime || localFile.length() != prior.size)
|
(localFile.lastModified() != prior.localMTime || localFile.length() != prior.size)
|
||||||
|
|
||||||
when {
|
when {
|
||||||
!localExists && !serverChanged -> {
|
!localExists -> {
|
||||||
// User deleted the local mirror copy themselves and the
|
// State says this file was mirrored but the local copy
|
||||||
// server hasn't changed - respect that deletion rather
|
// is gone (stale state left over from an earlier sync of
|
||||||
// than silently re-creating it.
|
// this path, or the file was removed outside the app).
|
||||||
actions.add(SyncAction.Delete(relPath, entry.fileId))
|
// 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 && localChanged -> actions.add(SyncAction.Conflict(entry, relPath))
|
||||||
serverChanged -> actions.add(SyncAction.Download(entry))
|
serverChanged -> actions.add(SyncAction.Download(entry))
|
||||||
@@ -380,6 +524,24 @@ object SyncEngine {
|
|||||||
for (fileId in toRemove) state.remove(fileId)
|
for (fileId in toRemove) state.remove(fileId)
|
||||||
saveState(context, accountId, state)
|
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)
|
val target = if (cleanPath.isEmpty()) root else File(root, cleanPath)
|
||||||
if (target.exists()) target.deleteRecursively()
|
if (target.exists()) target.deleteRecursively()
|
||||||
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
|
Log.d(TAG, "removeLocalSync($path) -> removed ${toRemove.size} state entries")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -32,27 +33,94 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
const val KEY_AUTH_HEADER = "authHeader"
|
const val KEY_AUTH_HEADER = "authHeader"
|
||||||
const val KEY_FOLDERS = "folders" // JSON array of remote file/folder paths
|
const val KEY_FOLDERS = "folders" // JSON array of remote file/folder paths
|
||||||
|
|
||||||
const val UNIQUE_PERIODIC_NAME = "noo_sync_periodic"
|
// True for a user-initiated "Sync now": always does a full walk of
|
||||||
const val UNIQUE_ONE_OFF_NAME = "noo_sync_now"
|
// 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"
|
||||||
|
|
||||||
private const val CHANNEL_ID = "device_sync"
|
// Whether automatic (non-force) runs may post progress/summary
|
||||||
private const val SUMMARY_NOTIFICATION_ID = 4401
|
// notifications - the "Background sync notifications" setting.
|
||||||
private const val CONFLICT_NOTIFICATION_ID_BASE = 4500
|
// 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"
|
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) {
|
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 accountId = inputData.getString(KEY_ACCOUNT_ID) ?: return@withContext Result.failure()
|
||||||
val serverUrl = inputData.getString(KEY_SERVER_URL) ?: 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 username = inputData.getString(KEY_USERNAME) ?: return@withContext Result.failure()
|
||||||
val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure()
|
val authHeader = inputData.getString(KEY_AUTH_HEADER) ?: return@withContext Result.failure()
|
||||||
val foldersJson = inputData.getString(KEY_FOLDERS) ?: "[]"
|
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) }
|
val folders = (0 until JSONArray(foldersJson).length()).map { JSONArray(foldersJson).getString(it) }
|
||||||
Log.d(TAG, "doWork: account=$accountId server=$serverUrl user=$username folders=$folders")
|
Log.d(TAG, "doWork: account=$accountId server=$serverUrl user=$username folders=$folders")
|
||||||
if (folders.isEmpty()) return@withContext Result.success()
|
if (folders.isEmpty()) return@withContext Result.success()
|
||||||
|
|
||||||
val syncRoot = SyncEngine.syncRoot(applicationContext, accountId)
|
val syncRoot = SyncEngine.syncRoot(applicationContext, accountId)
|
||||||
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
|
val state = SyncEngine.loadState(applicationContext, accountId).toMutableMap()
|
||||||
|
val markers = SyncEngine.loadRootMarkers(applicationContext, accountId)
|
||||||
|
|
||||||
var downloaded = 0
|
var downloaded = 0
|
||||||
var uploaded = 0
|
var uploaded = 0
|
||||||
@@ -60,81 +128,180 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
val conflicts = mutableListOf<Pair<SyncEngine.RemoteEntry, String>>()
|
val conflicts = mutableListOf<Pair<SyncEngine.RemoteEntry, String>>()
|
||||||
|
|
||||||
SyncStatusBus.setSyncing(accountId, true)
|
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 {
|
try {
|
||||||
for (path in folders) {
|
for (path in folders) {
|
||||||
// A configured path can be a file or a folder now - check
|
try {
|
||||||
// which before deciding whether to walk it recursively or
|
// A configured path can be a file or a folder now - check
|
||||||
// just diff the single item.
|
// which before deciding whether to walk it recursively or
|
||||||
val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path)
|
// just diff the single item.
|
||||||
val entries = when {
|
val self = SyncEngine.propfindSelf(serverUrl, username, authHeader, path)
|
||||||
self == null -> emptyList()
|
val pathPrefix = path.trimEnd('/') + "/"
|
||||||
self.isFolder -> SyncEngine.walkRemoteTree(serverUrl, username, authHeader, path)
|
val priorFileIdsForPath = state.filterValues {
|
||||||
else -> listOf(self)
|
it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/'))
|
||||||
}
|
}.keys
|
||||||
val pathPrefix = path.trimEnd('/') + "/"
|
|
||||||
val priorFileIdsForPath = state.filterValues {
|
|
||||||
it.relPath == path.trimStart('/') || it.relPath.startsWith(pathPrefix.trimStart('/'))
|
|
||||||
}.keys
|
|
||||||
|
|
||||||
val actions = SyncEngine.diffFolder(entries, state, priorFileIdsForPath, syncRoot)
|
// Cheap "did anything change?" check: an unchanged root etag
|
||||||
for (action in actions) {
|
// means nothing beneath it changed on the server, and if
|
||||||
when (action) {
|
// every local file is also still exactly as recorded there's
|
||||||
is SyncEngine.SyncAction.Download -> {
|
// nothing to upload or re-download either - so skip the walk
|
||||||
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, true)
|
// (one Depth-0 PROPFIND instead of one per subfolder).
|
||||||
val relPath = action.entry.path.removePrefix("/")
|
val marker = markers[path]
|
||||||
val dest = File(syncRoot, relPath)
|
if (!force && self != null && self.isFolder && marker != null &&
|
||||||
if (SyncEngine.downloadFile(serverUrl, username, authHeader, action.entry.path, dest)) {
|
marker.etag == self.etag &&
|
||||||
state[action.entry.fileId] = SyncEngine.FileState(
|
System.currentTimeMillis() - marker.fullWalkAt < FULL_WALK_MAX_AGE_MS &&
|
||||||
relPath = relPath,
|
priorFileIdsForPath.isNotEmpty() &&
|
||||||
etag = action.entry.etag,
|
SyncEngine.localMatchesState(state, priorFileIdsForPath, syncRoot)
|
||||||
lastModified = action.entry.lastModified,
|
) {
|
||||||
size = action.entry.size,
|
Log.d(TAG, "skip $path: server etag unchanged, local intact")
|
||||||
localMTime = dest.lastModified(),
|
continue
|
||||||
)
|
|
||||||
downloaded++
|
|
||||||
}
|
|
||||||
SyncStatusBus.markFileSyncing(accountId, action.entry.fileId, false)
|
|
||||||
}
|
|
||||||
is SyncEngine.SyncAction.Upload -> {
|
|
||||||
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++
|
|
||||||
}
|
|
||||||
SyncStatusBus.markFileSyncing(accountId, action.fileId, false)
|
|
||||||
}
|
|
||||||
is SyncEngine.SyncAction.Delete -> {
|
|
||||||
File(syncRoot, action.relPath).delete()
|
|
||||||
state.remove(action.fileId)
|
|
||||||
deleted++
|
|
||||||
}
|
|
||||||
is SyncEngine.SyncAction.Conflict -> conflicts.add(action.entry to action.relPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
} 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)
|
SyncStatusBus.setSyncing(accountId, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncEngine.saveState(applicationContext, accountId, state)
|
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
TAG,
|
||||||
"doWork done: downloaded=$downloaded uploaded=$uploaded deleted=$deleted conflicts=${conflicts.size}",
|
"doWork done: downloaded=$downloaded uploaded=$uploaded deleted=$deleted conflicts=${conflicts.size}",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (downloaded > 0 || uploaded > 0 || deleted > 0) {
|
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)
|
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()) {
|
if (conflicts.isNotEmpty()) {
|
||||||
SyncStatusBus.addConflicts(
|
SyncStatusBus.addConflicts(
|
||||||
@@ -155,6 +322,39 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
Result.success()
|
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) {
|
private fun notifySummary(downloaded: Int, uploaded: Int, deleted: Int) {
|
||||||
createChannel()
|
createChannel()
|
||||||
val parts = mutableListOf<String>()
|
val parts = mutableListOf<String>()
|
||||||
@@ -165,11 +365,12 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
.setContentTitle("Noo sync")
|
.setContentTitle("Noo sync · $notifyUsername")
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
.build()
|
.build()
|
||||||
manager().notify(SUMMARY_NOTIFICATION_ID, notification)
|
manager().notify(summaryNotificationId(notifyAccountId), notification)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyConflicts(
|
private fun notifyConflicts(
|
||||||
@@ -183,7 +384,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
for ((index, conflict) in conflicts.withIndex()) {
|
for ((index, conflict) in conflicts.withIndex()) {
|
||||||
val (entry, relPath) = conflict
|
val (entry, relPath) = conflict
|
||||||
val fileName = relPath.substringAfterLast('/')
|
val fileName = relPath.substringAfterLast('/')
|
||||||
val notificationId = CONFLICT_NOTIFICATION_ID_BASE + (relPath.hashCode() and 0xFFFF)
|
val notificationId = conflictNotificationId(accountId, relPath)
|
||||||
|
|
||||||
val useLocalIntent = conflictActionIntent(
|
val useLocalIntent = conflictActionIntent(
|
||||||
accountId,
|
accountId,
|
||||||
@@ -211,8 +412,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
.setContentTitle("Sync conflict: $fileName")
|
.setContentTitle("Sync conflict: $fileName")
|
||||||
.setContentText("Changed both on this device and on the server.")
|
.setContentText("$username - changed both on this device and on the server.")
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
.addAction(0, "Keep local", useLocalIntent)
|
.addAction(0, "Keep local", useLocalIntent)
|
||||||
.addAction(0, "Use server", useServerIntent)
|
.addAction(0, "Use server", useServerIntent)
|
||||||
.build()
|
.build()
|
||||||
@@ -245,7 +447,7 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
}
|
}
|
||||||
// Request code must be unique per (file, resolution) pair, else the
|
// Request code must be unique per (file, resolution) pair, else the
|
||||||
// two actions' PendingIntents collide and only one survives.
|
// two actions' PendingIntents collide and only one survives.
|
||||||
val requestCode = (relPath + resolution).hashCode()
|
val requestCode = (accountId + relPath + resolution).hashCode()
|
||||||
return PendingIntent.getBroadcast(
|
return PendingIntent.getBroadcast(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
requestCode,
|
requestCode,
|
||||||
@@ -262,8 +464,9 @@ class SyncWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
applicationContext,
|
applicationContext,
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
"Device sync",
|
"Device sync",
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
"Updates and conflicts for folders synced to this device",
|
"Updates and conflicts for folders synced to this device",
|
||||||
|
legacyIds = listOf(LEGACY_CHANNEL_ID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<inset
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
android:drawable="@drawable/ic_launcher_foreground"
|
</adaptive-icon>
|
||||||
android:inset="16%" />
|
|
||||||
</foreground>
|
|
||||||
<monochrome>
|
|
||||||
<inset
|
|
||||||
android:drawable="@drawable/ic_launcher_monochrome"
|
|
||||||
android:inset="16%" />
|
|
||||||
</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 |
|
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,325 @@
|
|||||||
|
// Drives the real app through its tabs and states against the fake-data demo
|
||||||
|
// server and captures the Play Store screenshots. Not part of the normal test
|
||||||
|
// suite (`flutter test` only runs `test/`) - run it through
|
||||||
|
// `tool/screenshots.sh`, which starts the demo server, an emulator and the
|
||||||
|
// driver in `test_driver/integration_test.dart` that saves the images.
|
||||||
|
//
|
||||||
|
// The app starts already logged in: the demo user's credentials are written
|
||||||
|
// straight into storage before `main()` runs, so Login Flow v2's browser
|
||||||
|
// step is skipped.
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:noo/main.dart' as app;
|
||||||
|
import 'package:noo/models/app_tab.dart';
|
||||||
|
import 'package:noo/models/saved_account.dart';
|
||||||
|
import 'package:noo/providers/files_controller.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
import 'package:noo/providers/sync_status_controller.dart';
|
||||||
|
import 'package:noo/services/account_store.dart';
|
||||||
|
import 'package:noo/theme/design_tokens.dart';
|
||||||
|
import 'package:noo/widgets/bottom_nav_bar.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const _serverUrl = String.fromEnvironment(
|
||||||
|
'DEMO_SERVER_URL',
|
||||||
|
defaultValue: 'http://localhost',
|
||||||
|
);
|
||||||
|
const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex');
|
||||||
|
const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'Play Store screenshots',
|
||||||
|
(tester) async {
|
||||||
|
expect(
|
||||||
|
_appPassword,
|
||||||
|
isNotEmpty,
|
||||||
|
reason: 'Pass the demo user\'s app password via '
|
||||||
|
'--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).',
|
||||||
|
);
|
||||||
|
await _seedLoggedInStorage();
|
||||||
|
|
||||||
|
app.main();
|
||||||
|
await tester.pump();
|
||||||
|
await _pumpUntil(tester, find.byType(app.MainShellView));
|
||||||
|
|
||||||
|
// Android only: required before `takeScreenshot()` can capture
|
||||||
|
// anything at all.
|
||||||
|
if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage();
|
||||||
|
|
||||||
|
Future<void> shoot(String name) async {
|
||||||
|
// Let scroll/transition animations and image fades finish first.
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await binding.takeScreenshot(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 01 Files (list) ------------------------------------------------
|
||||||
|
final filesTab = find.byKey(const ValueKey('files'));
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.descendant(of: filesTab, matching: find.text('Documents')),
|
||||||
|
);
|
||||||
|
await shoot('01_Files');
|
||||||
|
|
||||||
|
// ---- 02 Files (grid, inside a folder of photos) --------------------
|
||||||
|
final files = _read<FilesController>(tester);
|
||||||
|
await files.navigateToAbsoluteFolder('/Trips');
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')),
|
||||||
|
);
|
||||||
|
// The controls row scrolls horizontally and doesn't fit every control
|
||||||
|
// on a narrow screen, so "Grid view" can start off past the clipped
|
||||||
|
// edge - not just off the *current* tab (which `.hitTestable()` alone
|
||||||
|
// would handle), but off the *visible part of this tab's own row*.
|
||||||
|
// `_tapInTab` scrolls it into view first.
|
||||||
|
await _tapInTab(tester, filesTab, find.byTooltip('Grid view'));
|
||||||
|
await tester.pump(const Duration(seconds: 4)); // thumbnails
|
||||||
|
await shoot('02_Files_Grid');
|
||||||
|
|
||||||
|
// ---- 07 File viewer -------------------------------------------------
|
||||||
|
await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg'));
|
||||||
|
await tester.pump(const Duration(seconds: 4));
|
||||||
|
await shoot('07_Viewer');
|
||||||
|
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Back to the root folder, list view.
|
||||||
|
await _tapInTab(tester, filesTab, find.byTooltip('List view'));
|
||||||
|
await files.navigateToAbsoluteFolder('/');
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
// ---- 03 Photos ------------------------------------------------------
|
||||||
|
await _goToTab(tester, AppTab.photos);
|
||||||
|
await tester.pump(const Duration(seconds: 5)); // grid + thumbnails
|
||||||
|
await shoot('03_Photos');
|
||||||
|
|
||||||
|
// ---- 04 Favorites ---------------------------------------------------
|
||||||
|
await _goToTab(tester, AppTab.favorites);
|
||||||
|
await _pumpUntil(tester, find.text('Project brief.pdf'));
|
||||||
|
await shoot('04_Favorites');
|
||||||
|
|
||||||
|
// ---- 05 Activity ----------------------------------------------------
|
||||||
|
await _goToTab(tester, AppTab.activity);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
await shoot('05_Activity');
|
||||||
|
|
||||||
|
// ---- 06 Shares ------------------------------------------------------
|
||||||
|
await _goToTab(tester, AppTab.shares);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
await shoot('06_Shares');
|
||||||
|
|
||||||
|
// ---- 08 Sync status panel ------------------------------------------
|
||||||
|
// Sync two folders to the device; the header chip flips to "Synced"
|
||||||
|
// once the native worker has finished, and tapping it opens the panel.
|
||||||
|
await _goToTab(tester, AppTab.files);
|
||||||
|
await _read<SyncStatusController>(
|
||||||
|
tester,
|
||||||
|
).addSyncedPaths({'/Documents': true, '/Trips': true});
|
||||||
|
await _pumpUntil(tester, find.text('Synced'), timeout: 180);
|
||||||
|
await _tapInTab(tester, filesTab, find.text('Synced'));
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await shoot('08_Sync_Status');
|
||||||
|
await _tapInTab(tester, filesTab, find.byTooltip('Collapse'));
|
||||||
|
|
||||||
|
// ---- 09 Offline -----------------------------------------------------
|
||||||
|
_setVisibleTabs(tester, [
|
||||||
|
AppTab.files,
|
||||||
|
AppTab.photos,
|
||||||
|
AppTab.favorites,
|
||||||
|
AppTab.shares,
|
||||||
|
AppTab.offline,
|
||||||
|
]);
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
await _goToTab(tester, AppTab.offline);
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('offline')),
|
||||||
|
matching: find.text('Documents'),
|
||||||
|
),
|
||||||
|
timeout: 120,
|
||||||
|
);
|
||||||
|
await shoot('09_Offline');
|
||||||
|
|
||||||
|
// ---- 10/11 Settings -------------------------------------------------
|
||||||
|
await _tap(tester, find.byTooltip('Settings').hitTestable().first);
|
||||||
|
await _pumpUntil(tester, find.text('Accounts'));
|
||||||
|
await shoot('10_Settings');
|
||||||
|
// Wait for it, rather than assuming it's already built - ensureVisible
|
||||||
|
// itself has no retry/timeout, so on any transient delay (e.g. still
|
||||||
|
// mid-route-transition) it fails immediately instead of giving the
|
||||||
|
// frame a moment to catch up.
|
||||||
|
await _pumpUntil(tester, find.text('Files Cache'));
|
||||||
|
await tester.ensureVisible(find.text('Files Cache'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await shoot('11_Settings_Sync');
|
||||||
|
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// ---- 12/13 Dark theme -------------------------------------------------
|
||||||
|
final settings = _read<SettingsController>(tester);
|
||||||
|
settings.setThemeMode(ThemeMode.dark);
|
||||||
|
await _goToTab(tester, AppTab.files);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
await shoot('12_Files_Dark');
|
||||||
|
await _goToTab(tester, AppTab.photos);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
await shoot('13_Photos_Dark');
|
||||||
|
|
||||||
|
// ---- 14/15 Dark theme, AMOLED (pure black) -----------------------------
|
||||||
|
settings.setAmoledDark(true);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
await _goToTab(tester, AppTab.files);
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
await shoot('14_Files_Dark_Amoled');
|
||||||
|
await _goToTab(tester, AppTab.photos);
|
||||||
|
await tester.pump(const Duration(seconds: 3));
|
||||||
|
await shoot('15_Photos_Dark_Amoled');
|
||||||
|
},
|
||||||
|
timeout: const Timeout(Duration(minutes: 15)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts from empty storage, then writes exactly one saved account (the
|
||||||
|
/// demo user, with the app password from `--dart-define`) and the settings
|
||||||
|
/// that make screenshots reproducible: light theme, a fixed brand colour
|
||||||
|
/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav
|
||||||
|
/// tabs, and no first-run notification prompt.
|
||||||
|
Future<void> _seedLoggedInStorage() async {
|
||||||
|
await const FlutterSecureStorage().deleteAll();
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.clear();
|
||||||
|
|
||||||
|
final store = AccountStore();
|
||||||
|
final account = SavedAccount(
|
||||||
|
id: SavedAccount.makeId(_serverUrl, _username),
|
||||||
|
serverUrl: _serverUrl,
|
||||||
|
username: _username,
|
||||||
|
);
|
||||||
|
await store.saveAccounts(prefs, [account]);
|
||||||
|
await store.saveActiveAccountId(prefs, account.id);
|
||||||
|
await store.writePassword(account.id, _appPassword);
|
||||||
|
|
||||||
|
await prefs.setBool('notification_permission_asked', true);
|
||||||
|
await prefs.setString('ui_theme_mode', ThemeMode.light.name);
|
||||||
|
await prefs.setBool('ui_use_dynamic_color', false);
|
||||||
|
await prefs.setStringList('ui_tab_order', [
|
||||||
|
for (final t in AppTab.values) t.name,
|
||||||
|
]);
|
||||||
|
await prefs.setStringList('ui_hidden_tabs', [
|
||||||
|
AppTab.trash.name,
|
||||||
|
AppTab.recent.name,
|
||||||
|
AppTab.offline.name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
T _read<T>(WidgetTester tester) => Provider.of<T>(
|
||||||
|
tester.element(find.byType(app.MainShellView)),
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _tap(WidgetTester tester, Finder finder) async {
|
||||||
|
await tester.tap(finder);
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) -
|
||||||
|
/// for chrome that's duplicated across tabs (`IndexedStack` keeps every
|
||||||
|
/// visible tab's widget tree mounted, not just the active one) and/or can be
|
||||||
|
/// scrolled out of its own row's clipped viewport (the controls row,
|
||||||
|
/// `FilesControlsRow`, doesn't fit every control on a narrow screen).
|
||||||
|
/// Scoping to [tab] picks the right one of several identical matches;
|
||||||
|
/// `ensureVisible` scrolls it into view before tapping, which plain
|
||||||
|
/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look
|
||||||
|
/// like "zero hit-testable matches" and throw the same unhelpful
|
||||||
|
/// `Bad state: No element`.
|
||||||
|
Future<void> _tapInTab(WidgetTester tester, Finder tab, Finder matching) async {
|
||||||
|
final target = find.descendant(of: tab, matching: matching).first;
|
||||||
|
await tester.ensureVisible(target);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.tap(target, warnIfMissed: false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _goToTab(WidgetTester tester, AppTab tab) async {
|
||||||
|
// Only one bottom nav bar exists (outside the IndexedStack), so no
|
||||||
|
// cross-tab duplicate to worry about - but it can itself overflow a
|
||||||
|
// narrow screen, so still scroll the target into view rather than relying
|
||||||
|
// on hitTestable() alone (see `_tapInTab`'s doc comment).
|
||||||
|
final target = find
|
||||||
|
.descendant(
|
||||||
|
of: find.byType(BottomNavBar),
|
||||||
|
matching: find.byIcon(tab.icon),
|
||||||
|
)
|
||||||
|
.first;
|
||||||
|
await tester.ensureVisible(target);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.tap(target, warnIfMissed: false);
|
||||||
|
await tester.pump(const Duration(milliseconds: 600));
|
||||||
|
|
||||||
|
// Confirm the tap actually landed, rather than trusting it silently - a
|
||||||
|
// scroll-imprecise tap (see above) that misses its target doesn't throw,
|
||||||
|
// it just does nothing, which would otherwise leave every later
|
||||||
|
// screenshot quietly showing whatever tab was already selected instead of
|
||||||
|
// failing loudly. Every destination shows its label all the time now (see
|
||||||
|
// `BottomNavBar`'s doc comment), so label presence can't signal selection
|
||||||
|
// - the icon's color can: it's `accentText` only for the active tab.
|
||||||
|
await _pumpUntilTrue(tester, () {
|
||||||
|
if (target.evaluate().isEmpty) return false;
|
||||||
|
final icon = tester.widget<Icon>(target);
|
||||||
|
final colors = tester.element(target).nooColors;
|
||||||
|
return icon.color == colors.accentText;
|
||||||
|
}, timeout: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the
|
||||||
|
/// five-tab cap is never hit).
|
||||||
|
void _setVisibleTabs(WidgetTester tester, List<AppTab> tabs) {
|
||||||
|
final settings = _read<SettingsController>(tester);
|
||||||
|
for (final tab in AppTab.values) {
|
||||||
|
if (!tabs.contains(tab)) settings.setTabHidden(tab, true);
|
||||||
|
}
|
||||||
|
for (final tab in tabs) {
|
||||||
|
settings.setTabHidden(tab, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits (in real time - this is a live binding, and the app is talking to a
|
||||||
|
/// real server) until [finder] matches something.
|
||||||
|
Future<void> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
int timeout = 60,
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(Duration(seconds: timeout));
|
||||||
|
while (finder.evaluate().isEmpty) {
|
||||||
|
if (DateTime.now().isAfter(deadline)) {
|
||||||
|
throw TestFailure('Timed out after ${timeout}s waiting for $finder');
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [_pumpUntil], but for a condition that isn't just "does a finder
|
||||||
|
/// match something" (e.g. a widget property).
|
||||||
|
Future<void> _pumpUntilTrue(
|
||||||
|
WidgetTester tester,
|
||||||
|
bool Function() predicate, {
|
||||||
|
int timeout = 60,
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(Duration(seconds: timeout));
|
||||||
|
while (!predicate()) {
|
||||||
|
if (DateTime.now().isAfter(deadline)) {
|
||||||
|
throw TestFailure('Timed out after ${timeout}s waiting for condition');
|
||||||
|
}
|
||||||
|
await tester.pump(const Duration(milliseconds: 250));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'models/app_tab.dart';
|
import 'models/app_tab.dart';
|
||||||
import 'models/pick_request.dart';
|
import 'models/pick_request.dart';
|
||||||
|
import 'providers/connectivity_controller.dart';
|
||||||
import 'providers/favorites_controller.dart';
|
import 'providers/favorites_controller.dart';
|
||||||
import 'providers/files_controller.dart';
|
import 'providers/files_controller.dart';
|
||||||
import 'providers/item_operations.dart';
|
import 'providers/item_operations.dart';
|
||||||
|
import 'providers/offline_controller.dart';
|
||||||
import 'providers/photos_controller.dart';
|
import 'providers/photos_controller.dart';
|
||||||
import 'providers/pick_controller.dart';
|
import 'providers/pick_controller.dart';
|
||||||
import 'providers/recent_controller.dart';
|
import 'providers/recent_controller.dart';
|
||||||
@@ -20,12 +23,24 @@ import 'providers/trash_controller.dart';
|
|||||||
import 'services/pick_intent_service.dart';
|
import 'services/pick_intent_service.dart';
|
||||||
import 'services/share_intent_service.dart';
|
import 'services/share_intent_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'theme/design_tokens.dart';
|
||||||
import 'views/lock_screen_view.dart';
|
import 'views/lock_screen_view.dart';
|
||||||
import 'views/login_view.dart';
|
import 'views/login_view.dart';
|
||||||
import 'views/search_view.dart';
|
|
||||||
import 'views/share_upload_view.dart';
|
import 'views/share_upload_view.dart';
|
||||||
|
import 'widgets/app_drawer.dart';
|
||||||
import 'widgets/app_tab_view_builder.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() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -38,17 +53,28 @@ void main() {
|
|||||||
// replaced/recreated for the app's lifetime (account switching is
|
// replaced/recreated for the app's lifetime (account switching is
|
||||||
// internal state on SessionController, not a new provider instance).
|
// internal state on SessionController, not a new provider instance).
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => SessionController()),
|
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||||
ChangeNotifierProvider(create: (_) => SettingsController()),
|
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => SyncStatusController(context.read()),
|
create: (context) => SessionController(context.read()),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||||
|
// Before SyncStatusController, which follows its Files Cache rule
|
||||||
|
// for how often synced files refresh.
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => FilesController(context.read()),
|
create: (context) => FilesController(context.read()),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
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) =>
|
create: (context) =>
|
||||||
PhotosController(context.read(), context.read()),
|
SyncStatusController(context.read(), context.read()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => PhotosController(context.read(), context.read()),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
@@ -66,6 +92,12 @@ void main() {
|
|||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => PickController(context.read()),
|
create: (context) => PickController(context.read()),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
// Eager for the same reason as SyncStatusController above.
|
||||||
|
lazy: false,
|
||||||
|
create: (context) =>
|
||||||
|
OfflineController(context.read(), context.read()),
|
||||||
|
),
|
||||||
Provider(
|
Provider(
|
||||||
create: (context) => ItemOperations(
|
create: (context) => ItemOperations(
|
||||||
session: context.read(),
|
session: context.read(),
|
||||||
@@ -123,21 +155,19 @@ class _SplashView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colors = context.nooColors;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: colors.bg,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// The monochrome adaptive-icon layer is a plain white silhouette
|
// The monochrome adaptive-icon layer is a plain white silhouette
|
||||||
// on transparent, meant to be tinted rather than shown as-is -
|
// on transparent, meant to be tinted rather than shown as-is -
|
||||||
// srcIn recolors it to the theme's foreground so it reads
|
// srcIn recolors it to fg-1 so it reads correctly in both light
|
||||||
// correctly in both light and dark mode.
|
// and dark mode.
|
||||||
ColorFiltered(
|
ColorFiltered(
|
||||||
colorFilter: ColorFilter.mode(
|
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
||||||
colorScheme.onSurface,
|
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/icon/app_icon_monochrome.png',
|
'assets/icon/app_icon_monochrome.png',
|
||||||
width: 72,
|
width: 72,
|
||||||
@@ -150,7 +180,7 @@ class _SplashView extends StatelessWidget {
|
|||||||
height: 22,
|
height: 22,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
color: colorScheme.primary,
|
color: colors.accent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -226,26 +256,34 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
if (status.isGranted || status.isPermanentlyDenied) return;
|
if (status.isGranted || status.isPermanentlyDenied) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final shouldRequest = await showDialog<bool>(
|
final colors = context.nooColors;
|
||||||
context: context,
|
final shouldRequest = await showNooDialog<bool>(
|
||||||
|
context,
|
||||||
|
title: 'Allow notifications?',
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
children: [
|
||||||
title: const Text('Allow notifications?'),
|
Text(
|
||||||
content: const Text(
|
|
||||||
'Noo shows upload and download progress as a notification, so '
|
'Noo shows upload and download progress as a notification, so '
|
||||||
'you can track transfers that keep running in the background.',
|
'you can track transfers that keep running in the background.',
|
||||||
|
style: NooText.body.copyWith(color: colors.fg2),
|
||||||
),
|
),
|
||||||
actions: [
|
Row(
|
||||||
TextButton(
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
spacing: 10,
|
||||||
child: const Text('Not now'),
|
children: [
|
||||||
),
|
NooButton(
|
||||||
FilledButton(
|
variant: NooButtonVariant.secondary,
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onTap: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('Allow'),
|
child: const Text('Not now'),
|
||||||
),
|
),
|
||||||
],
|
NooButton(
|
||||||
),
|
variant: NooButtonVariant.primary,
|
||||||
|
onTap: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Allow'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
if (shouldRequest == true) {
|
if (shouldRequest == true) {
|
||||||
await Permission.notification.request();
|
await Permission.notification.request();
|
||||||
@@ -274,8 +312,15 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
final pick = context.watch<PickController>();
|
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 -
|
// 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
|
// consumed here so it only fires once, then cleared after this frame
|
||||||
@@ -294,20 +339,235 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
// real "pick a file from here" destinations, and only these two tabs'
|
// real "pick a file from here" destinations, and only these two tabs'
|
||||||
// views even know how to handle picking-mode taps. This overrides the
|
// views even know how to handle picking-mode taps. This overrides the
|
||||||
// user's own hidden/reordered tab settings rather than respecting
|
// user's own hidden/reordered tab settings rather than respecting
|
||||||
// them, since picking is a separate mode from normal browsing.
|
// them, since picking is a separate mode from normal browsing. With
|
||||||
final visible = pickRequest != null
|
// 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]
|
? [AppTab.files, AppTab.photos]
|
||||||
|
: connectivity.isOffline
|
||||||
|
? [AppTab.offline]
|
||||||
: settings.visibleTabs;
|
: settings.visibleTabs;
|
||||||
final selectedTab = visible.contains(_currentTab)
|
// The drawer/sidebar's "More" section - hidden while an override above
|
||||||
? _currentTab
|
// is active, same as the drawer itself being unavailable during pick.
|
||||||
: (visible.isNotEmpty ? visible.first : AppTab.files);
|
final hiddenTabs = overrideActive
|
||||||
final selectedIndex = visible
|
? const <AppTab>[]
|
||||||
.indexOf(selectedTab)
|
: settings.tabOrder
|
||||||
.clamp(0, visible.isEmpty ? 0 : visible.length - 1);
|
.where((t) => settings.hiddenTabs.contains(t))
|
||||||
|
.toList();
|
||||||
|
|
||||||
final navItems = visible
|
// A tab opened from "More" isn't one of the pinned destinations, but
|
||||||
.map((tab) => FloatingNavItem(label: tab.label, icon: tab.icon))
|
// should stay shown and selected (with no destination highlighted in
|
||||||
.toList();
|
// 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: displayTabs
|
||||||
|
.map(
|
||||||
|
(tab) => buildAppTabView(
|
||||||
|
tab,
|
||||||
|
_scrollControllers[tab]!,
|
||||||
|
topBar: isDesktop || pickRequest != null
|
||||||
|
? null
|
||||||
|
: AppTopBar(
|
||||||
|
style: navStyle,
|
||||||
|
tab: tab,
|
||||||
|
searchInBottomBar: showBottomBarSearch,
|
||||||
|
navMenuStyle: settings.navMenuStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final canUpload =
|
||||||
|
pickRequest == null &&
|
||||||
|
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
|
||||||
|
|
||||||
|
Widget scaffold;
|
||||||
|
if (isDesktop) {
|
||||||
|
scaffold = Scaffold(
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
body: 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),
|
||||||
|
),
|
||||||
|
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
|
||||||
|
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,
|
||||||
|
// 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: !canUpload,
|
||||||
|
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 = pinnedTabs[index];
|
||||||
|
if (tappedTab == _currentTab) {
|
||||||
|
if (settings.tapTabToScrollTop) {
|
||||||
|
final controller = _scrollControllers[tappedTab];
|
||||||
|
// `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),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectTab(tappedTab);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
// While picking, system back cancels the pick (and tells the caller
|
// While picking, system back cancels the pick (and tells the caller
|
||||||
@@ -317,53 +577,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && pickRequest != null) pick.cancelPick();
|
if (!didPop && pickRequest != null) pick.cancelPick();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: scaffold,
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
IndexedStack(
|
|
||||||
index: selectedIndex,
|
|
||||||
children: visible
|
|
||||||
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
|
||||||
// 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: settings.bottomBarOpacity,
|
|
||||||
blurSigma: settings.bottomBarBlur,
|
|
||||||
onDestinationSelected: (index) {
|
|
||||||
final tappedTab = visible[index];
|
|
||||||
if (tappedTab == _currentTab) {
|
|
||||||
if (settings.tapTabToScrollTop) {
|
|
||||||
final controller = _scrollControllers[tappedTab];
|
|
||||||
if (controller != null && controller.hasClients) {
|
|
||||||
controller.animateTo(
|
|
||||||
0,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_currentTab = tappedTab;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSearchTap: () {
|
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (_) => const SearchView()));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,21 +590,27 @@ class _PickingProgressOverlay extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
return Positioned.fill(
|
return Positioned.fill(
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: Colors.black.withValues(alpha: 0.35),
|
color: colors.scrim,
|
||||||
child: const Center(
|
child: Center(
|
||||||
child: Card(
|
child: Container(
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(24),
|
||||||
padding: EdgeInsets.all(24),
|
decoration: BoxDecoration(
|
||||||
child: Column(
|
color: colors.surface,
|
||||||
mainAxisSize: MainAxisSize.min,
|
borderRadius: BorderRadius.circular(NooRadii.dialog),
|
||||||
children: [
|
),
|
||||||
CircularProgressIndicator(),
|
child: Column(
|
||||||
SizedBox(height: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Text('Preparing file...'),
|
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: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
|
/// Every destination the bottom nav bar can show. Order here is only the
|
||||||
/// fallback default — actual order/visibility/default-tab are user
|
/// fallback default — actual order/visibility/default-tab are user
|
||||||
/// configurable and persisted in `SettingsController`.
|
/// configurable and persisted in `SettingsController`.
|
||||||
enum AppTab { files, photos, favorites, activity, trash, shares, recent }
|
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 —
|
/// At most this many tabs may be visible in the bottom nav bar at once —
|
||||||
/// the rest are reachable through the "more" dropdown instead.
|
/// the rest are reachable through the "more" dropdown instead. The
|
||||||
const int maxVisibleTabs = 5;
|
/// 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 {
|
extension AppTabInfo on AppTab {
|
||||||
String get label {
|
String get label {
|
||||||
@@ -27,25 +41,31 @@ extension AppTabInfo on AppTab {
|
|||||||
return 'Shares';
|
return 'Shares';
|
||||||
case AppTab.recent:
|
case AppTab.recent:
|
||||||
return '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 {
|
IconData get icon {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case AppTab.files:
|
case AppTab.files:
|
||||||
return Icons.folder_rounded;
|
return LucideIcons.folder;
|
||||||
case AppTab.photos:
|
case AppTab.photos:
|
||||||
return Icons.photo_library_rounded;
|
return LucideIcons.images;
|
||||||
case AppTab.favorites:
|
case AppTab.favorites:
|
||||||
return Icons.favorite_rounded;
|
return LucideIcons.star;
|
||||||
case AppTab.activity:
|
case AppTab.activity:
|
||||||
return Icons.electric_bolt_rounded;
|
return LucideIcons.activity;
|
||||||
case AppTab.trash:
|
case AppTab.trash:
|
||||||
return Icons.delete_outline_rounded;
|
return LucideIcons.trash2; // trash-2
|
||||||
case AppTab.shares:
|
case AppTab.shares:
|
||||||
return Icons.groups_rounded;
|
return LucideIcons.share2; // share-2
|
||||||
case AppTab.recent:
|
case AppTab.recent:
|
||||||
return Symbols.search_activity_rounded;
|
return LucideIcons.clock;
|
||||||
|
case AppTab.offline:
|
||||||
|
return LucideIcons.hardDriveDownload; // hard-drive-download
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,91 @@
|
|||||||
import 'package:flutter/material.dart';
|
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,
|
/// One bulk action available for the current multi-selection (e.g. favorite,
|
||||||
/// download, delete), shown as an icon button in the sticky selection
|
/// download, delete), shown as an icon button in the sticky selection
|
||||||
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
|
/// toolbar each tab (Files, Photos, ...) renders inline in its own content.
|
||||||
class SelectionAction {
|
class SelectionAction {
|
||||||
|
final SelectionActionKind kind;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const SelectionAction({
|
const SelectionAction({
|
||||||
|
required this.kind,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
|
import '../services/upload_service.dart';
|
||||||
|
import 'folder_browser.dart';
|
||||||
import 'session_controller.dart';
|
import 'session_controller.dart';
|
||||||
|
|
||||||
enum FileSortField { name, dateCreated, dateModified, size }
|
enum FileSortField { name, dateCreated, dateModified, size }
|
||||||
@@ -20,7 +22,8 @@ enum FilesTypeFilter { all, filesOnly, foldersOnly }
|
|||||||
/// content - just the list of names/sizes/dates) are reused across
|
/// content - just the list of names/sizes/dates) are reused across
|
||||||
/// navigation instead of refetched from the server every time.
|
/// navigation instead of refetched from the server every time.
|
||||||
enum CachePolicy {
|
enum CachePolicy {
|
||||||
/// Every navigation refetches - today's behavior.
|
/// Every navigation refetches. Synced files are checked each time the app
|
||||||
|
/// opens (and on pull-to-refresh), but not on a background schedule.
|
||||||
never,
|
never,
|
||||||
|
|
||||||
/// A listing is reused until it's older than [FilesController.
|
/// A listing is reused until it's older than [FilesController.
|
||||||
@@ -29,9 +32,17 @@ enum CachePolicy {
|
|||||||
interval,
|
interval,
|
||||||
|
|
||||||
/// A listing is reused indefinitely until the user pulls to refresh.
|
/// A listing is reused indefinitely until the user pulls to refresh.
|
||||||
|
/// Synced files likewise only update on pull-to-refresh / "Sync now".
|
||||||
manual,
|
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.
|
/// One cached folder listing and when it was fetched.
|
||||||
class _CachedDirectory {
|
class _CachedDirectory {
|
||||||
final List<NextcloudItem> items;
|
final List<NextcloudItem> items;
|
||||||
@@ -52,7 +63,9 @@ class _CachedDirectory {
|
|||||||
/// comments for why they depend on this one.
|
/// comments for why they depend on this one.
|
||||||
///
|
///
|
||||||
/// Split out of the former single `ServerProvider` god object.
|
/// Split out of the former single `ServerProvider` god object.
|
||||||
class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
class FilesController extends ChangeNotifier
|
||||||
|
with WidgetsBindingObserver
|
||||||
|
implements FolderBrowser {
|
||||||
final SessionController session;
|
final SessionController session;
|
||||||
|
|
||||||
static const _prefGridView = 'ui_grid_view';
|
static const _prefGridView = 'ui_grid_view';
|
||||||
@@ -60,8 +73,8 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||||
static const _prefFolderSort = 'ui_folder_sort';
|
static const _prefFolderSort = 'ui_folder_sort';
|
||||||
static const _prefCachePolicy = 'ui_cache_policy';
|
static const prefCachePolicy = 'ui_cache_policy';
|
||||||
static const _prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||||
|
|
||||||
String _currentFolderPath = '/';
|
String _currentFolderPath = '/';
|
||||||
List<String> _pathStack = ['/'];
|
List<String> _pathStack = ['/'];
|
||||||
@@ -74,11 +87,16 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final Map<String, FileSortField> _folderSortField = {};
|
final Map<String, FileSortField> _folderSortField = {};
|
||||||
final Map<String, bool> _folderSortAscending = {};
|
final Map<String, bool> _folderSortAscending = {};
|
||||||
|
|
||||||
CachePolicy _cachePolicy = CachePolicy.never;
|
CachePolicy _cachePolicy = defaultCachePolicy;
|
||||||
int _cacheIntervalMinutes = 5;
|
int _cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||||
final Map<String, _CachedDirectory> _directoryCache = {};
|
final Map<String, _CachedDirectory> _directoryCache = {};
|
||||||
Timer? _cacheRefreshTimer;
|
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 = [];
|
List<NextcloudItem> _items = [];
|
||||||
NextcloudUserQuota? _quota;
|
NextcloudUserQuota? _quota;
|
||||||
List<NextcloudActivity> _activities = [];
|
List<NextcloudActivity> _activities = [];
|
||||||
@@ -89,26 +107,53 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
session.addAccountClearedListener(_onAccountCleared);
|
session.addAccountClearedListener(_onAccountCleared);
|
||||||
session.addAccountActivatedListener(_onAccountActivated);
|
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
|
// Getters
|
||||||
|
@override
|
||||||
String get currentFolderPath => _currentFolderPath;
|
String get currentFolderPath => _currentFolderPath;
|
||||||
|
@override
|
||||||
List<String> get pathStack => _pathStack;
|
List<String> get pathStack => _pathStack;
|
||||||
bool get isGridView => _isGridView;
|
bool get isGridView => _isGridView;
|
||||||
StorageScope get storageScope => _storageScope;
|
StorageScope get storageScope => _storageScope;
|
||||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||||
bool get showHiddenFiles => _showHiddenFiles;
|
bool get showHiddenFiles => _showHiddenFiles;
|
||||||
FileSortField get filesSortField =>
|
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||||
_folderSortField[_currentFolderPath] ?? FileSortField.name;
|
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||||
bool get filesSortAscending =>
|
|
||||||
_folderSortAscending[_currentFolderPath] ?? true;
|
/// 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;
|
CachePolicy get cachePolicy => _cachePolicy;
|
||||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||||
|
@override
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
|
@override
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
NextcloudUserQuota? get quota => _quota;
|
NextcloudUserQuota? get quota => _quota;
|
||||||
List<NextcloudActivity> get activities => _activities;
|
List<NextcloudActivity> get activities => _activities;
|
||||||
|
|
||||||
|
@override
|
||||||
List<NextcloudItem> get items => applyFilesDisplayPrefs(_items);
|
List<NextcloudItem> get items => applyFilesDisplayPrefs(_items);
|
||||||
|
|
||||||
int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) {
|
int _compareItems(NextcloudItem a, NextcloudItem b, FileSortField field) {
|
||||||
@@ -140,6 +185,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
List<NextcloudItem> source, {
|
List<NextcloudItem> source, {
|
||||||
required bool showFavoritesOnly,
|
required bool showFavoritesOnly,
|
||||||
required bool showHidden,
|
required bool showHidden,
|
||||||
|
bool applyStorageScope = true,
|
||||||
}) {
|
}) {
|
||||||
var filtered = source;
|
var filtered = source;
|
||||||
if (showFavoritesOnly) {
|
if (showFavoritesOnly) {
|
||||||
@@ -148,13 +194,15 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (!showHidden) {
|
if (!showHidden) {
|
||||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||||
}
|
}
|
||||||
filtered = filtered
|
if (applyStorageScope) {
|
||||||
.where(
|
filtered = filtered
|
||||||
(item) => _storageScope == StorageScope.external
|
.where(
|
||||||
? item.isExternalStorage
|
(item) => _storageScope == StorageScope.external
|
||||||
: !item.isExternalStorage,
|
? item.isExternalStorage
|
||||||
)
|
: !item.isExternalStorage,
|
||||||
.toList();
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,13 +213,22 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// listings via [fetchFolderListing] rather than reading [items] itself)
|
/// listings via [fetchFolderListing] rather than reading [items] itself)
|
||||||
/// can render with the exact same controls/behavior as the Files tab
|
/// can render with the exact same controls/behavior as the Files tab
|
||||||
/// without duplicating this logic.
|
/// without duplicating this logic.
|
||||||
List<NextcloudItem> applyFilesDisplayPrefs(List<NextcloudItem> rawItems) {
|
///
|
||||||
|
/// [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(
|
var filtered = applyCommonFilters(
|
||||||
rawItems,
|
rawItems,
|
||||||
// Files itself doesn't filter by favorite - that's the dedicated
|
// Files itself doesn't filter by favorite - that's the dedicated
|
||||||
// Favorites tab's job.
|
// Favorites tab's job.
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
showHidden: _showHiddenFiles,
|
showHidden: _showHiddenFiles,
|
||||||
|
applyStorageScope: applyStorageScope,
|
||||||
);
|
);
|
||||||
switch (_filesTypeFilter) {
|
switch (_filesTypeFilter) {
|
||||||
case FilesTypeFilter.all:
|
case FilesTypeFilter.all:
|
||||||
@@ -182,17 +239,21 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
filtered = filtered.where((i) => i.isFolder).toList();
|
filtered = filtered.where((i) => i.isFolder).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
final field = filesSortField;
|
final path = folderPath ?? _currentFolderPath;
|
||||||
|
final field = sortFieldFor(path);
|
||||||
final folders = filtered.where((i) => i.isFolder).toList()
|
final folders = filtered.where((i) => i.isFolder).toList()
|
||||||
..sort((a, b) => _compareItems(a, b, field));
|
..sort((a, b) => _compareItems(a, b, field));
|
||||||
final files = filtered.where((i) => !i.isFolder).toList()
|
final files = filtered.where((i) => !i.isFolder).toList()
|
||||||
..sort((a, b) => _compareItems(a, b, field));
|
..sort((a, b) => _compareItems(a, b, field));
|
||||||
return filesSortAscending
|
return sortAscendingFor(path)
|
||||||
? [...folders, ...files]
|
? [...folders, ...files]
|
||||||
: [...folders.reversed, ...files.reversed];
|
: [...folders.reversed, ...files.reversed];
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAccountCleared() {
|
void _onAccountCleared() {
|
||||||
|
_prefsRestore = null;
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
_retryAttempt = 0;
|
||||||
_cacheRefreshTimer?.cancel();
|
_cacheRefreshTimer?.cancel();
|
||||||
_directoryCache.clear();
|
_directoryCache.clear();
|
||||||
_folderSortField.clear();
|
_folderSortField.clear();
|
||||||
@@ -206,61 +267,100 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_storageScope = StorageScope.cloud;
|
_storageScope = StorageScope.cloud;
|
||||||
_filesTypeFilter = FilesTypeFilter.all;
|
_filesTypeFilter = FilesTypeFilter.all;
|
||||||
_showHiddenFiles = false;
|
_showHiddenFiles = false;
|
||||||
_cachePolicy = CachePolicy.never;
|
_cachePolicy = defaultCachePolicy;
|
||||||
_cacheIntervalMinutes = 5;
|
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAccountActivated() async {
|
// 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;
|
final id = session.activeAccountId;
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
final prefs = await session.prefsFuture;
|
// The whole prefs-restore block is wrapped, not just the JSON
|
||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
// 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;
|
_isGridView = prefs.getBool(k(_prefGridView)) ?? false;
|
||||||
final storageScopeName = prefs.getString(k(_prefStorageScope));
|
final storageScopeName = prefs.getString(k(_prefStorageScope));
|
||||||
_storageScope = StorageScope.values.firstWhere(
|
_storageScope = StorageScope.values.firstWhere(
|
||||||
(s) => s.name == storageScopeName,
|
(s) => s.name == storageScopeName,
|
||||||
orElse: () => StorageScope.cloud,
|
orElse: () => StorageScope.cloud,
|
||||||
);
|
);
|
||||||
final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter));
|
final filesTypeFilterName = prefs.getString(k(_prefFilesTypeFilter));
|
||||||
_filesTypeFilter = FilesTypeFilter.values.firstWhere(
|
_filesTypeFilter = FilesTypeFilter.values.firstWhere(
|
||||||
(f) => f.name == filesTypeFilterName,
|
(f) => f.name == filesTypeFilterName,
|
||||||
orElse: () => FilesTypeFilter.all,
|
orElse: () => FilesTypeFilter.all,
|
||||||
);
|
);
|
||||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||||
|
|
||||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||||
if (folderSortJson != null) {
|
if (folderSortJson != null) {
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
|
final decoded = jsonDecode(folderSortJson) as Map<String, dynamic>;
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
final value = entry.value as Map<String, dynamic>;
|
final value = entry.value as Map<String, dynamic>;
|
||||||
final fieldName = value['field'] as String?;
|
final fieldName = value['field'] as String?;
|
||||||
if (fieldName != null) {
|
if (fieldName != null) {
|
||||||
_folderSortField[entry.key] = FileSortField.values.firstWhere(
|
_folderSortField[entry.key] = FileSortField.values.firstWhere(
|
||||||
(f) => f.name == fieldName,
|
(f) => f.name == fieldName,
|
||||||
orElse: () => FileSortField.name,
|
orElse: () => FileSortField.name,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
final ascending = value['ascending'] as bool?;
|
||||||
|
if (ascending != null) {
|
||||||
|
_folderSortAscending[entry.key] = ascending;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final ascending = value['ascending'] as bool?;
|
} catch (e) {
|
||||||
if (ascending != null) _folderSortAscending[entry.key] = ascending;
|
debugPrint('[FilesController] Folder sort restore failed: $e');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[FilesController] Folder sort restore failed: $e');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final cachePolicyName = prefs.getString(k(_prefCachePolicy));
|
final cachePolicyName = prefs.getString(k(prefCachePolicy));
|
||||||
_cachePolicy = CachePolicy.values.firstWhere(
|
_cachePolicy = CachePolicy.values.firstWhere(
|
||||||
(c) => c.name == cachePolicyName,
|
(c) => c.name == cachePolicyName,
|
||||||
orElse: () => CachePolicy.never,
|
orElse: () => defaultCachePolicy,
|
||||||
);
|
);
|
||||||
_cacheIntervalMinutes = prefs.getInt(k(_prefCacheIntervalMinutes)) ?? 5;
|
_cacheIntervalMinutes =
|
||||||
notifyListeners();
|
prefs.getInt(k(prefCacheIntervalMinutes)) ??
|
||||||
|
defaultCacheIntervalMinutes;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'[FilesController] Account-activation prefs restore failed: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAccountActivated() async {
|
||||||
|
await _restoreDisplayPrefs();
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
await refreshData();
|
await refreshData();
|
||||||
}
|
}
|
||||||
@@ -305,12 +405,25 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
// can recognize it's for an account the user has already left and
|
// can recognize it's for an account the user has already left and
|
||||||
// discard itself instead of overwriting the new account's content.
|
// discard itself instead of overwriting the new account's content.
|
||||||
final gen = session.sessionGeneration;
|
final gen = session.sessionGeneration;
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
|
||||||
_isLoading = true;
|
// Refreshing a folder that's already on screen (pull-to-refresh, the
|
||||||
_errorMessage = null;
|
// periodic cache refresh) swaps the listing in place once the server
|
||||||
notifyListeners();
|
// 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');
|
debugPrint(
|
||||||
|
'[FilesController] Refreshing data for path: $_currentFolderPath',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final items = await service.fetchDirectory(_currentFolderPath);
|
final items = await service.fetchDirectory(_currentFolderPath);
|
||||||
@@ -320,6 +433,11 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_items,
|
_items,
|
||||||
DateTime.now(),
|
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(
|
debugPrint(
|
||||||
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
|
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
|
||||||
);
|
);
|
||||||
@@ -336,7 +454,12 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen != session.sessionGeneration) return;
|
if (gen != session.sessionGeneration) return;
|
||||||
debugPrint('[FilesController] Error fetching directory: $e');
|
debugPrint('[FilesController] Error fetching directory: $e');
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
// 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 {
|
} finally {
|
||||||
if (gen == session.sessionGeneration) {
|
if (gen == session.sessionGeneration) {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -345,17 +468,46 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
Future<List<NextcloudItem>> searchFiles(String query) async {
|
||||||
final service = session.service;
|
final service = session.service;
|
||||||
if (service == null) return [];
|
if (service == null) return [];
|
||||||
return service.searchFiles(query);
|
return service.searchFiles(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reload() => refreshData();
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> navigateToFolder(String path) async {
|
Future<void> navigateToFolder(String path) async {
|
||||||
_pathStack.add(path);
|
_pathStack.add(path);
|
||||||
await _navigateTo(path);
|
await _navigateTo(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Future<void> navigateUp() async {
|
Future<void> navigateUp() async {
|
||||||
if (_pathStack.length > 1) {
|
if (_pathStack.length > 1) {
|
||||||
_pathStack.removeLast();
|
_pathStack.removeLast();
|
||||||
@@ -365,6 +517,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
/// Jumps directly to an ancestor folder by its position in [pathStack]
|
/// Jumps directly to an ancestor folder by its position in [pathStack]
|
||||||
/// (as tapped from a breadcrumb), trimming everything below it.
|
/// (as tapped from a breadcrumb), trimming everything below it.
|
||||||
|
@override
|
||||||
Future<void> navigateToPathIndex(int index) async {
|
Future<void> navigateToPathIndex(int index) async {
|
||||||
if (index < 0 || index >= _pathStack.length - 1) return;
|
if (index < 0 || index >= _pathStack.length - 1) return;
|
||||||
_pathStack = _pathStack.sublist(0, index + 1);
|
_pathStack = _pathStack.sublist(0, index + 1);
|
||||||
@@ -402,6 +555,10 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
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();
|
await refreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +571,20 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return session.service?.fetchDirectory(path) ?? Future.value([]);
|
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();
|
void invalidateCache() => _directoryCache.clear();
|
||||||
|
|
||||||
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
|
/// Persists a per-account browsing pref under its `acct_<id>_`-namespaced
|
||||||
@@ -493,15 +664,20 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setFilesSortField(FileSortField field) {
|
void setFilesSortField(FileSortField field) =>
|
||||||
if (filesSortField == field) return;
|
setSortFieldFor(_currentFolderPath, field);
|
||||||
_folderSortField[_currentFolderPath] = field;
|
|
||||||
|
void toggleFilesSortOrder() => toggleSortOrderFor(_currentFolderPath);
|
||||||
|
|
||||||
|
void setSortFieldFor(String path, FileSortField field) {
|
||||||
|
if (sortFieldFor(path) == field) return;
|
||||||
|
_folderSortField[path] = field;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistFolderSort();
|
_persistFolderSort();
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggleFilesSortOrder() {
|
void toggleSortOrderFor(String path) {
|
||||||
_folderSortAscending[_currentFolderPath] = !filesSortAscending;
|
_folderSortAscending[path] = !sortAscendingFor(path);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistFolderSort();
|
_persistFolderSort();
|
||||||
}
|
}
|
||||||
@@ -511,7 +687,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_cachePolicy = policy;
|
_cachePolicy = policy;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefCachePolicy,
|
prefCachePolicy,
|
||||||
(p, key) => p.setString(key, policy.name),
|
(p, key) => p.setString(key, policy.name),
|
||||||
);
|
);
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
@@ -523,7 +699,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_cacheIntervalMinutes = clamped;
|
_cacheIntervalMinutes = clamped;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefCacheIntervalMinutes,
|
prefCacheIntervalMinutes,
|
||||||
(p, key) => p.setInt(key, clamped),
|
(p, key) => p.setInt(key, clamped),
|
||||||
);
|
);
|
||||||
_startCacheRefreshTimerIfNeeded();
|
_startCacheRefreshTimerIfNeeded();
|
||||||
@@ -552,6 +728,7 @@ class FilesController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_cacheRefreshTimer?.cancel();
|
_cacheRefreshTimer?.cancel();
|
||||||
|
_uploadSub?.cancel();
|
||||||
super.dispose();
|
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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,18 +76,27 @@ class PhotosController extends ChangeNotifier {
|
|||||||
Future<void> _onAccountActivated() async {
|
Future<void> _onAccountActivated() async {
|
||||||
final id = session.activeAccountId;
|
final id = session.activeAccountId;
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
final prefs = await session.prefsFuture;
|
// Wrapped so a single bad/mistyped stored pref value can't
|
||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
// silently skip fetchAllMedia() below and leave Photos permanently
|
||||||
_showFavoritesOnly =
|
// empty on this activation with no error surfaced anywhere - see
|
||||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
// FilesController._onAccountActivated's identical guard for the
|
||||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
// real incident this mirrors.
|
||||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
try {
|
||||||
_sortField = FileSortField.values.firstWhere(
|
final prefs = await session.prefsFuture;
|
||||||
(f) => f.name == sortFieldName,
|
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||||
orElse: () => FileSortField.name,
|
_showFavoritesOnly =
|
||||||
);
|
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||||
_sortAscending = prefs.getBool(k(_prefSortAscending)) ?? true;
|
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||||
notifyListeners();
|
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());
|
unawaited(fetchAllMedia());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../services/app_lock_service.dart';
|
|||||||
import '../services/login_flow_service.dart';
|
import '../services/login_flow_service.dart';
|
||||||
import '../services/nextcloud_service.dart';
|
import '../services/nextcloud_service.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
|
import 'connectivity_controller.dart';
|
||||||
|
|
||||||
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
|
enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
|
||||||
|
|
||||||
@@ -25,10 +26,20 @@ enum LoginFlowStatus { idle, initiating, awaitingBrowser, error }
|
|||||||
/// `.claude/context/architecture.md`'s "State management" section for the
|
/// `.claude/context/architecture.md`'s "State management" section for the
|
||||||
/// full rationale.
|
/// full rationale.
|
||||||
class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
final Future<SharedPreferences> prefsFuture =
|
final ConnectivityController connectivity;
|
||||||
SharedPreferences.getInstance();
|
final Future<SharedPreferences> prefsFuture = SharedPreferences.getInstance();
|
||||||
final AccountStore accountStore = AccountStore();
|
final AccountStore accountStore = AccountStore();
|
||||||
|
|
||||||
|
// True from the moment a saved session is restored (or a network
|
||||||
|
// failure keeps it alive - see `_applyCredentialsForAccount`) without
|
||||||
|
// ever having actually reached the server, until a real
|
||||||
|
// `testConnection()` succeeds. Network-fetching controllers have no
|
||||||
|
// reason to check this themselves - they only ever hear about account
|
||||||
|
// activation via [addAccountActivatedListener], which simply doesn't
|
||||||
|
// fire while this is true (see [addAccountReadyListener] for the
|
||||||
|
// signal that *does* fire either way).
|
||||||
|
bool _isProvisionalLogin = false;
|
||||||
|
|
||||||
String _serverUrl = '';
|
String _serverUrl = '';
|
||||||
String _username = '';
|
String _username = '';
|
||||||
String _password = '';
|
String _password = '';
|
||||||
@@ -71,13 +82,45 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
// them - it's constructed first, they depend on it. `cleared` fires
|
// them - it's constructed first, they depend on it. `cleared` fires
|
||||||
// wherever `_clearActiveContent()` used to run (about to switch/log out -
|
// wherever `_clearActiveContent()` used to run (about to switch/log out -
|
||||||
// reset your own state); `activated` fires only once credentials are
|
// reset your own state); `activated` fires only once credentials are
|
||||||
// actually verified (fetch your own data now).
|
// actually verified against the server, so it's safe to make network
|
||||||
|
// requests (fetch your own data now) - it deliberately does *not* fire
|
||||||
|
// for a provisional/offline login (see `_isProvisionalLogin`), so a
|
||||||
|
// network-fetching controller never has to check connectivity itself.
|
||||||
|
// `ready` fires in *both* cases - verified or provisional - for the
|
||||||
|
// handful of controllers (SyncStatusController/OfflineController) whose
|
||||||
|
// own activation work is local-only (prefs, on-disk files) and safe to
|
||||||
|
// run with no connection at all.
|
||||||
final List<VoidCallback> _accountClearedListeners = [];
|
final List<VoidCallback> _accountClearedListeners = [];
|
||||||
final List<VoidCallback> _accountActivatedListeners = [];
|
final List<VoidCallback> _accountActivatedListeners = [];
|
||||||
|
final List<VoidCallback> _accountReadyListeners = [];
|
||||||
void addAccountClearedListener(VoidCallback cb) =>
|
void addAccountClearedListener(VoidCallback cb) =>
|
||||||
_accountClearedListeners.add(cb);
|
_accountClearedListeners.add(cb);
|
||||||
void addAccountActivatedListener(VoidCallback cb) =>
|
|
||||||
_accountActivatedListeners.add(cb);
|
// Both `activated` and `ready` are one-shot, fire-and-forget calls, not a
|
||||||
|
// replayable stream - so a controller isn't guaranteed to be *listening*
|
||||||
|
// yet when the real event fires. Most controllers are registered with
|
||||||
|
// `lazy: false` in main.dart specifically so they exist before that can
|
||||||
|
// happen, but one that isn't (built lazily, on first read, like
|
||||||
|
// FilesController) can lose the race if nothing reads it until after
|
||||||
|
// login already finished verifying - e.g. `ConnectivityController`
|
||||||
|
// misreporting offline right at cold start collapses the bottom nav to
|
||||||
|
// just the Offline tab (see `main.dart`), so Files' tab, and the
|
||||||
|
// `FilesController` it lazily creates, never gets built during that
|
||||||
|
// window. Calling back immediately here if the account is *already* in
|
||||||
|
// the state being subscribed to closes that race for every current and
|
||||||
|
// future subscriber, without each one needing its own view-level
|
||||||
|
// fallback reload - see `.claude/context/architecture.md`'s "State
|
||||||
|
// management" section for the guardrail this exists to enforce.
|
||||||
|
void addAccountActivatedListener(VoidCallback cb) {
|
||||||
|
_accountActivatedListeners.add(cb);
|
||||||
|
if (_isLoggedIn && !_isProvisionalLogin) cb();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addAccountReadyListener(VoidCallback cb) {
|
||||||
|
_accountReadyListeners.add(cb);
|
||||||
|
if (_isLoggedIn) cb();
|
||||||
|
}
|
||||||
|
|
||||||
void _notifyAccountCleared() {
|
void _notifyAccountCleared() {
|
||||||
for (final cb in _accountClearedListeners) {
|
for (final cb in _accountClearedListeners) {
|
||||||
cb();
|
cb();
|
||||||
@@ -88,10 +131,20 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
for (final cb in _accountActivatedListeners) {
|
for (final cb in _accountActivatedListeners) {
|
||||||
cb();
|
cb();
|
||||||
}
|
}
|
||||||
|
for (final cb in _accountReadyListeners) {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SessionController() {
|
void _notifyAccountProvisionallyReady() {
|
||||||
|
for (final cb in _accountReadyListeners) {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionController(this.connectivity) {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
connectivity.addListener(_onConnectivityChanged);
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,9 +162,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
// one-time unlock at cold start would give the feature no real
|
// one-time unlock at cold start would give the feature no real
|
||||||
// security value, since the realistic threat is someone else picking
|
// security value, since the realistic threat is someone else picking
|
||||||
// up an already-running, unlocked phone.
|
// up an already-running, unlocked phone.
|
||||||
if (state == AppLifecycleState.paused &&
|
if (state == AppLifecycleState.paused && _loginLockEnabled && _isUnlocked) {
|
||||||
_loginLockEnabled &&
|
|
||||||
_isUnlocked) {
|
|
||||||
_isUnlocked = false;
|
_isUnlocked = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -175,11 +226,16 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
|
|
||||||
/// Verifies [appPassword] for [account] and, on success, makes it the
|
/// Verifies [appPassword] for [account] and, on success, makes it the
|
||||||
/// live session, firing [_notifyAccountActivated] so every other
|
/// live session, firing [_notifyAccountActivated] so every other
|
||||||
/// controller fetches its own data. The password is expected to already
|
/// controller fetches its own data. With no network route at all, or on
|
||||||
/// be durably saved by the caller (either freshly, via
|
/// a network-level (not auth) failure, skips/gives up on verification
|
||||||
/// [_completeLoginFlow], or previously, since this is also how a saved
|
/// but still logs in *provisionally* - see [_isProvisionalLogin] - so
|
||||||
/// session is restored/switched to) - this method only writes to
|
/// the app still opens into `MainShellView` (restricted to the Offline
|
||||||
/// [AccountStore] to drop a password that turns out to no longer work.
|
/// tab while offline) instead of bouncing to `LoginView`. The password
|
||||||
|
/// is expected to already be durably saved by the caller (either
|
||||||
|
/// freshly, via [_completeLoginFlow], or previously, since this is also
|
||||||
|
/// how a saved session is restored/switched to) - this method only
|
||||||
|
/// writes to [AccountStore] to drop a password that turns out to no
|
||||||
|
/// longer work (an actual 401, never a network failure).
|
||||||
Future<bool> _applyCredentialsForAccount(
|
Future<bool> _applyCredentialsForAccount(
|
||||||
SavedAccount account,
|
SavedAccount account,
|
||||||
String appPassword,
|
String appPassword,
|
||||||
@@ -199,10 +255,25 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
password: _password,
|
password: _password,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// No point even attempting the request with no network route at all -
|
||||||
|
// treat this exactly like the network-failure branch below (stay
|
||||||
|
// logged in provisionally, `service` already assigned) without paying
|
||||||
|
// for a doomed HTTP call first.
|
||||||
|
if (connectivity.isOffline) {
|
||||||
|
if (gen != _sessionGeneration) return false;
|
||||||
|
_isProvisionalLogin = true;
|
||||||
|
_isLoggedIn = true;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
_notifyAccountProvisionallyReady();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final success = await _service!.testConnection();
|
final success = await _service!.testConnection();
|
||||||
if (gen != _sessionGeneration) return false;
|
if (gen != _sessionGeneration) return false;
|
||||||
if (success) {
|
if (success) {
|
||||||
|
_isProvisionalLogin = false;
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -212,20 +283,35 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen != _sessionGeneration) return false;
|
if (gen != _sessionGeneration) return false;
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
// Only drop the stored password on an actual auth rejection (401) -
|
// Only drop the stored password - and only actually log out - on an
|
||||||
// testConnection also throws for network-level failures (DNS,
|
// actual auth rejection (401). Every other failure (DNS, timeout,
|
||||||
// timeout, unreachable host), and those are transient: deleting a
|
// unreachable host, a transient 5xx) is provisional: staying
|
||||||
// still-valid password on a dropped connection would permanently
|
// "logged in" with the cached account/service lets the app still
|
||||||
// log the account out with no way back in short of Login Flow v2
|
// land on MainShellView instead of bouncing to LoginView, which
|
||||||
// again, since activeAccountId is never cleared and switchAccount()
|
// would strand a genuinely offline user with no way back in short
|
||||||
// no-ops when asked to "switch" to the account that's already
|
// of Login Flow v2 again (activeAccountId is never cleared and
|
||||||
// (nominally) active.
|
// switchAccount() no-ops when asked to "switch" to the account
|
||||||
|
// that's already nominally active). `_service` is already assigned
|
||||||
|
// above and needs no re-creation once connectivity returns.
|
||||||
if (_errorMessage?.contains('401') == true) {
|
if (_errorMessage?.contains('401') == true) {
|
||||||
await accountStore.deletePassword(account.id);
|
await accountStore.deletePassword(account.id);
|
||||||
|
_isProvisionalLogin = false;
|
||||||
|
_isLoggedIn = false;
|
||||||
|
_service = null;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
_isProvisionalLogin = true;
|
||||||
|
_isLoggedIn = true;
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
_notifyAccountProvisionallyReady();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gen != _sessionGeneration) return false;
|
if (gen != _sessionGeneration) return false;
|
||||||
|
_isProvisionalLogin = false;
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
_service = null;
|
_service = null;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -233,6 +319,41 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Once connectivity actually returns, re-verifies a provisional/offline
|
||||||
|
/// login for real - this is what finally fires [_notifyAccountActivated]
|
||||||
|
/// (not just [_notifyAccountProvisionallyReady]) so network-fetching
|
||||||
|
/// controllers get their first real fetch without requiring an app
|
||||||
|
/// restart.
|
||||||
|
void _onConnectivityChanged() {
|
||||||
|
_reverifyTimer?.cancel();
|
||||||
|
if (!_isProvisionalLogin || connectivity.isOffline) return;
|
||||||
|
unawaited(_reverify(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer? _reverifyTimer;
|
||||||
|
|
||||||
|
/// One re-verification attempt, retried with a growing delay if the
|
||||||
|
/// server still can't be reached. The OS reports "connected" a moment
|
||||||
|
/// before the route actually works (the first request right after
|
||||||
|
/// regaining Wi-Fi/data fails with "Network is unreachable"), and
|
||||||
|
/// `onConnectivityChanged` won't fire again to give a second chance - so
|
||||||
|
/// without retrying, the login stayed provisional and the Files tab showed
|
||||||
|
/// a connection error until the user tapped Retry.
|
||||||
|
Future<void> _reverify(int attempt) async {
|
||||||
|
if (!_isProvisionalLogin || connectivity.isOffline) return;
|
||||||
|
final id = _activeAccountId;
|
||||||
|
if (id == null) return;
|
||||||
|
final account = _accounts.where((a) => a.id == id).firstOrNull;
|
||||||
|
if (account == null) return;
|
||||||
|
await _applyCredentialsForAccount(account, _password);
|
||||||
|
if (_isProvisionalLogin && !connectivity.isOffline && attempt < 5) {
|
||||||
|
_reverifyTimer = Timer(
|
||||||
|
Duration(seconds: 2 * (attempt + 1)),
|
||||||
|
() => unawaited(_reverify(attempt + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
|
/// Starts Nextcloud Login Flow v2: asks the server for a one-time login
|
||||||
/// URL, then polls until the user authorizes (in LoginWebViewView, pushed
|
/// URL, then polls until the user authorizes (in LoginWebViewView, pushed
|
||||||
/// by LoginView once loginFlowStatus flips to awaitingBrowser below) and
|
/// by LoginView once loginFlowStatus flips to awaitingBrowser below) and
|
||||||
@@ -370,6 +491,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_activeAccountId = accountId;
|
_activeAccountId = accountId;
|
||||||
final prefs = await prefsFuture;
|
final prefs = await prefsFuture;
|
||||||
await accountStore.saveActiveAccountId(prefs, accountId);
|
await accountStore.saveActiveAccountId(prefs, accountId);
|
||||||
|
// Signed back in (or switched to) - it syncs in the background again.
|
||||||
|
await accountStore.setSignedOut(prefs, accountId, false);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
final account = _accounts.where((a) => a.id == accountId).firstOrNull;
|
final account = _accounts.where((a) => a.id == accountId).firstOrNull;
|
||||||
@@ -377,6 +500,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
? null
|
? null
|
||||||
: await accountStore.readPassword(accountId);
|
: await accountStore.readPassword(accountId);
|
||||||
if (account == null || password == null) {
|
if (account == null || password == null) {
|
||||||
|
_isProvisionalLogin = false;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -443,6 +567,9 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
for (final key in AccountStore.perAccountPrefKeys) {
|
for (final key in AccountStore.perAccountPrefKeys) {
|
||||||
await prefs.remove(accountStore.accountPrefKey(accountId, key));
|
await prefs.remove(accountStore.accountPrefKey(accountId, key));
|
||||||
}
|
}
|
||||||
|
await accountStore.setSignedOut(prefs, accountId, false);
|
||||||
|
// Its background sync job holds that account's credentials - stop it.
|
||||||
|
unawaited(SyncService.cancelAccount(accountId));
|
||||||
|
|
||||||
if (!wasActive) {
|
if (!wasActive) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -466,6 +593,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
Future<void> _deactivateSession() async {
|
Future<void> _deactivateSession() async {
|
||||||
_sessionGeneration++;
|
_sessionGeneration++;
|
||||||
_notifyAccountCleared();
|
_notifyAccountCleared();
|
||||||
|
_isProvisionalLogin = false;
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
_serverUrl = '';
|
_serverUrl = '';
|
||||||
_username = '';
|
_username = '';
|
||||||
@@ -474,7 +602,6 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
final prefs = await prefsFuture;
|
final prefs = await prefsFuture;
|
||||||
await accountStore.saveActiveAccountId(prefs, null);
|
await accountStore.saveActiveAccountId(prefs, null);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
unawaited(SyncService.cancel());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the active session but keeps this account saved - unlike
|
/// Ends the active session but keeps this account saved - unlike
|
||||||
@@ -484,8 +611,16 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// Always lands on the login screen even if other accounts are saved -
|
/// Always lands on the login screen even if other accounts are saved -
|
||||||
/// deliberately not the same as switching to one of them.
|
/// deliberately not the same as switching to one of them.
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
if (_activeAccountId == null) return;
|
final id = _activeAccountId;
|
||||||
|
if (id == null) return;
|
||||||
|
// Logging out stops that account's background sync (the other saved
|
||||||
|
// accounts keep syncing - each has its own job). It's remembered as
|
||||||
|
// signed out so a later reschedule for some other account doesn't
|
||||||
|
// bring its job back; signing in again re-enables it.
|
||||||
|
final prefs = await prefsFuture;
|
||||||
|
await accountStore.setSignedOut(prefs, id, true);
|
||||||
await _deactivateSession();
|
await _deactivateSession();
|
||||||
|
unawaited(SyncService.cancelAccount(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirms the device can do local auth and prompts once to enable login
|
/// Confirms the device can do local auth and prompts once to enable login
|
||||||
@@ -564,6 +699,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
connectivity.removeListener(_onConnectivityChanged);
|
||||||
|
_reverifyTimer?.cancel();
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
_pollTimeoutTimer?.cancel();
|
_pollTimeoutTimer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import '../models/selection_action.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/noo/nav/noo_nav_style.dart';
|
||||||
|
|
||||||
/// What swiping a Files list-view item left/right does, user-configurable
|
/// What swiping a Files list-view item left/right does, user-configurable
|
||||||
/// in Settings.
|
/// in Settings.
|
||||||
@@ -23,13 +25,15 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefThemeMode = 'ui_theme_mode';
|
static const _prefThemeMode = 'ui_theme_mode';
|
||||||
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
|
static const _prefUseDynamicColor = 'ui_use_dynamic_color';
|
||||||
static const _prefSeedColor = 'ui_seed_color';
|
static const _prefSeedColor = 'ui_seed_color';
|
||||||
static const _prefBottomBarOpacity = 'ui_bottom_bar_opacity';
|
|
||||||
static const _prefBottomBarBlur = 'ui_bottom_bar_blur';
|
|
||||||
static const _prefTabOrder = 'ui_tab_order';
|
static const _prefTabOrder = 'ui_tab_order';
|
||||||
|
static const _prefSelectionActionOrder = 'ui_selection_action_order';
|
||||||
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
static const _prefHiddenTabs = 'ui_hidden_tabs';
|
||||||
static const _prefDefaultTab = 'ui_default_tab';
|
static const _prefDefaultTab = 'ui_default_tab';
|
||||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
|
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||||
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
||||||
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
||||||
@@ -37,20 +41,24 @@ class SettingsController extends ChangeNotifier {
|
|||||||
final Future<SharedPreferences> _prefsFuture =
|
final Future<SharedPreferences> _prefsFuture =
|
||||||
SharedPreferences.getInstance();
|
SharedPreferences.getInstance();
|
||||||
|
|
||||||
Color _seedColor = AppTheme.defaultNextcloudBlue;
|
Color _seedColor = AppTheme.defaultAccent;
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
||||||
|
|
||||||
double _bottomBarOpacity = 0.55;
|
|
||||||
double _bottomBarBlur = 28;
|
|
||||||
bool _tapTabToScrollTop = true;
|
bool _tapTabToScrollTop = true;
|
||||||
|
|
||||||
List<AppTab> _tabOrder = AppTab.values.toList();
|
List<AppTab> _tabOrder = AppTab.values.toList();
|
||||||
Set<AppTab> _hiddenTabs = {};
|
Set<AppTab> _hiddenTabs = {};
|
||||||
AppTab _defaultTab = AppTab.files;
|
AppTab _defaultTab = AppTab.files;
|
||||||
|
|
||||||
|
List<SelectionActionKind> _selectionActionOrder = SelectionActionKind.values
|
||||||
|
.toList();
|
||||||
|
|
||||||
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
||||||
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
||||||
|
|
||||||
@@ -79,19 +87,34 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
bool get amoledDark => _amoledDark;
|
bool get amoledDark => _amoledDark;
|
||||||
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
||||||
double get bottomBarOpacity => _bottomBarOpacity;
|
|
||||||
double get bottomBarBlur => _bottomBarBlur;
|
|
||||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||||
|
|
||||||
|
/// The cap a screen should actually enforce for the *regular*, user-
|
||||||
|
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
||||||
|
/// [searchInBottomBar] is on, since Search then takes that freed-up slot
|
||||||
|
/// itself (the row's last tab when attached, its own satellite circle
|
||||||
|
/// when floating - see `NooBottomBar`) rather than counting against it.
|
||||||
|
int get maxVisibleTabs =>
|
||||||
|
defaultMaxVisibleTabs - (_searchInBottomBar ? 1 : 0);
|
||||||
|
|
||||||
/// Every tab in the user's configured order, including hidden ones — used
|
/// Every tab in the user's configured order, including hidden ones — used
|
||||||
/// by the reorder/visibility settings UI.
|
/// by the reorder/visibility settings UI.
|
||||||
List<AppTab> get tabOrder => _tabOrder;
|
List<AppTab> get tabOrder => _tabOrder;
|
||||||
Set<AppTab> get hiddenTabs => _hiddenTabs;
|
Set<AppTab> get hiddenTabs => _hiddenTabs;
|
||||||
AppTab get defaultTab => _defaultTab;
|
AppTab get defaultTab => _defaultTab;
|
||||||
|
|
||||||
|
/// The priority order bulk actions (favorite, share, download, ...) show
|
||||||
|
/// in on the multi-select action bar - see [orderSelectionActions]. Every
|
||||||
|
/// [SelectionActionKind] is always present here (nothing is hidden, only
|
||||||
|
/// reordered), so [NooSelectionBar]'s fixed inline slots are always full.
|
||||||
|
List<SelectionActionKind> get selectionActionOrder => _selectionActionOrder;
|
||||||
|
|
||||||
/// The tabs the bottom nav bar should actually show, in order.
|
/// The tabs the bottom nav bar should actually show, in order.
|
||||||
List<AppTab> get visibleTabs =>
|
List<AppTab> get visibleTabs =>
|
||||||
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
|
_tabOrder.where((t) => !_hiddenTabs.contains(t)).toList();
|
||||||
@@ -110,6 +133,22 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => ThemeMode.system,
|
orElse: () => ThemeMode.system,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
||||||
|
if (bottomBarStyleName != null) {
|
||||||
|
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
||||||
|
(s) => s.name == bottomBarStyleName,
|
||||||
|
orElse: () => _bottomBarStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
||||||
|
if (navMenuStyleName != null) {
|
||||||
|
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
||||||
|
(s) => s.name == navMenuStyleName,
|
||||||
|
orElse: () => _navMenuStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_searchInBottomBar =
|
||||||
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
|
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
|
||||||
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
|
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
|
||||||
@@ -122,9 +161,6 @@ class SettingsController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
final seedColorValue = prefs.getInt(_prefSeedColor);
|
final seedColorValue = prefs.getInt(_prefSeedColor);
|
||||||
if (seedColorValue != null) _seedColor = Color(seedColorValue);
|
if (seedColorValue != null) _seedColor = Color(seedColorValue);
|
||||||
_bottomBarOpacity =
|
|
||||||
prefs.getDouble(_prefBottomBarOpacity) ?? _bottomBarOpacity;
|
|
||||||
_bottomBarBlur = prefs.getDouble(_prefBottomBarBlur) ?? _bottomBarBlur;
|
|
||||||
_tapTabToScrollTop =
|
_tapTabToScrollTop =
|
||||||
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
prefs.getBool(_prefTapTabToScrollTop) ?? _tapTabToScrollTop;
|
||||||
|
|
||||||
@@ -143,6 +179,25 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_tabOrder = order;
|
_tabOrder = order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final savedActionOrderNames = prefs.getStringList(
|
||||||
|
_prefSelectionActionOrder,
|
||||||
|
);
|
||||||
|
if (savedActionOrderNames != null) {
|
||||||
|
final order = <SelectionActionKind>[];
|
||||||
|
for (final name in savedActionOrderNames) {
|
||||||
|
final match = SelectionActionKind.values
|
||||||
|
.where((k) => k.name == name)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null) order.add(match);
|
||||||
|
}
|
||||||
|
// Forward-compat: a kind added in a later app update won't be in an
|
||||||
|
// older saved order yet, so append anything missing.
|
||||||
|
for (final kind in SelectionActionKind.values) {
|
||||||
|
if (!order.contains(kind)) order.add(kind);
|
||||||
|
}
|
||||||
|
_selectionActionOrder = order;
|
||||||
|
}
|
||||||
|
|
||||||
final savedHiddenNames = prefs.getStringList(_prefHiddenTabs);
|
final savedHiddenNames = prefs.getStringList(_prefHiddenTabs);
|
||||||
if (savedHiddenNames != null) {
|
if (savedHiddenNames != null) {
|
||||||
_hiddenTabs = savedHiddenNames
|
_hiddenTabs = savedHiddenNames
|
||||||
@@ -223,18 +278,6 @@ class SettingsController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setBottomBarOpacity(double value) {
|
|
||||||
_bottomBarOpacity = value;
|
|
||||||
notifyListeners();
|
|
||||||
_prefsFuture.then((p) => p.setDouble(_prefBottomBarOpacity, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
void setBottomBarBlur(double value) {
|
|
||||||
_bottomBarBlur = value;
|
|
||||||
notifyListeners();
|
|
||||||
_prefsFuture.then((p) => p.setDouble(_prefBottomBarBlur, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
void setTapTabToScrollTop(bool value) {
|
void setTapTabToScrollTop(bool value) {
|
||||||
_tapTabToScrollTop = value;
|
_tapTabToScrollTop = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -247,6 +290,32 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setBottomBarStyle(NooBottomBarStyle style) {
|
||||||
|
if (_bottomBarStyle == style) return;
|
||||||
|
_bottomBarStyle = style;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||||
|
if (_navMenuStyle == style) return;
|
||||||
|
_navMenuStyle = style;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefNavMenuStyle, style.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap
|
||||||
|
/// immediately in case the user already has a full 5 regular tabs pinned
|
||||||
|
/// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh
|
||||||
|
/// install/an app update adding a new tab.
|
||||||
|
void setSearchInBottomBar(bool value) {
|
||||||
|
if (_searchInBottomBar == value) return;
|
||||||
|
_searchInBottomBar = value;
|
||||||
|
if (value) _enforceMaxVisibleTabs();
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setBool(_prefSearchInBottomBar, value));
|
||||||
|
}
|
||||||
|
|
||||||
void setTabOrder(List<AppTab> order) {
|
void setTabOrder(List<AppTab> order) {
|
||||||
_tabOrder = order;
|
_tabOrder = order;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -255,6 +324,17 @@ class SettingsController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setSelectionActionOrder(List<SelectionActionKind> order) {
|
||||||
|
_selectionActionOrder = order;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then(
|
||||||
|
(p) => p.setStringList(
|
||||||
|
_prefSelectionActionOrder,
|
||||||
|
order.map((k) => k.name).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Shows or hides a tab in the bottom nav bar. Refuses to hide the last
|
/// Shows or hides a tab in the bottom nav bar. Refuses to hide the last
|
||||||
/// remaining visible tab. If the tab being hidden is the current default,
|
/// remaining visible tab. If the tab being hidden is the current default,
|
||||||
/// the default falls back to the next visible tab.
|
/// the default falls back to the next visible tab.
|
||||||
|
|||||||
@@ -8,7 +8,16 @@ import 'session_controller.dart';
|
|||||||
class SharesController extends ChangeNotifier {
|
class SharesController extends ChangeNotifier {
|
||||||
final SessionController session;
|
final SessionController session;
|
||||||
|
|
||||||
List<NextcloudShare> _shares = [];
|
/// Each scope's own cache - switching `sharedWithMe` no longer refetches
|
||||||
|
/// on every switch, only the first time a scope is visited (or on a
|
||||||
|
/// pull-to-refresh/retry), since `By you`/`Links` (see shares_view.dart's
|
||||||
|
/// client-side split of this same list) and `With you` used to hit the
|
||||||
|
/// network again on every single toggle, even switching straight back to
|
||||||
|
/// a scope just fetched seconds ago.
|
||||||
|
List<NextcloudShare> _withMeShares = [];
|
||||||
|
List<NextcloudShare> _byMeShares = [];
|
||||||
|
bool _withMeLoaded = false;
|
||||||
|
bool _byMeLoaded = false;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
bool _sharedWithMe = false;
|
bool _sharedWithMe = false;
|
||||||
@@ -18,13 +27,17 @@ class SharesController extends ChangeNotifier {
|
|||||||
session.addAccountActivatedListener(_onAccountActivated);
|
session.addAccountActivatedListener(_onAccountActivated);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<NextcloudShare> get shares => _shares;
|
List<NextcloudShare> get shares =>
|
||||||
|
_sharedWithMe ? _withMeShares : _byMeShares;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
bool get sharedWithMe => _sharedWithMe;
|
bool get sharedWithMe => _sharedWithMe;
|
||||||
|
|
||||||
void _onAccountCleared() {
|
void _onAccountCleared() {
|
||||||
_shares = [];
|
_withMeShares = [];
|
||||||
|
_byMeShares = [];
|
||||||
|
_withMeLoaded = false;
|
||||||
|
_byMeLoaded = false;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_sharedWithMe = false;
|
_sharedWithMe = false;
|
||||||
@@ -35,19 +48,29 @@ class SharesController extends ChangeNotifier {
|
|||||||
unawaited(fetchAll());
|
unawaited(fetchAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the *current* scope fresh, ignoring its cache - the initial
|
||||||
|
/// load, pull-to-refresh, and the error-retry button all want a real
|
||||||
|
/// round-trip regardless of whether this scope was already loaded.
|
||||||
Future<void> fetchAll() async {
|
Future<void> fetchAll() async {
|
||||||
final service = session.service;
|
final service = session.service;
|
||||||
if (!session.isLoggedIn || service == null) return;
|
if (!session.isLoggedIn || service == null) return;
|
||||||
final gen = session.sessionGeneration;
|
final gen = session.sessionGeneration;
|
||||||
|
final wantsWithMe = _sharedWithMe;
|
||||||
|
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final shares = await service.fetchShares(sharedWithMe: _sharedWithMe);
|
final shares = await service.fetchShares(sharedWithMe: wantsWithMe);
|
||||||
if (gen != session.sessionGeneration) return;
|
if (gen != session.sessionGeneration) return;
|
||||||
_shares = shares;
|
if (wantsWithMe) {
|
||||||
|
_withMeShares = shares;
|
||||||
|
_withMeLoaded = true;
|
||||||
|
} else {
|
||||||
|
_byMeShares = shares;
|
||||||
|
_byMeLoaded = true;
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen != session.sessionGeneration) return;
|
if (gen != session.sessionGeneration) return;
|
||||||
debugPrint('[SharesController] Error fetching shares: $e');
|
debugPrint('[SharesController] Error fetching shares: $e');
|
||||||
@@ -60,12 +83,14 @@ class SharesController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches between "shared by me" and "shared with me" and refetches.
|
/// Switches between "shared by me" and "shared with me". Only fetches
|
||||||
|
/// when the target scope hasn't been loaded yet - switching back to one
|
||||||
|
/// already cached just swaps which list [shares] reads from.
|
||||||
void setSharedWithMe(bool value) {
|
void setSharedWithMe(bool value) {
|
||||||
if (_sharedWithMe == value) return;
|
if (_sharedWithMe == value) return;
|
||||||
_sharedWithMe = value;
|
_sharedWithMe = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
fetchAll();
|
if (!(value ? _withMeLoaded : _byMeLoaded)) fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> deleteShare(NextcloudShare share) async {
|
Future<bool> deleteShare(NextcloudShare share) async {
|
||||||
@@ -73,7 +98,11 @@ class SharesController extends ChangeNotifier {
|
|||||||
if (service == null) return false;
|
if (service == null) return false;
|
||||||
final success = await service.deleteShare(share.id);
|
final success = await service.deleteShare(share.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
_shares = _shares.where((s) => s.id != share.id).toList();
|
if (_sharedWithMe) {
|
||||||
|
_withMeShares = _withMeShares.where((s) => s.id != share.id).toList();
|
||||||
|
} else {
|
||||||
|
_byMeShares = _byMeShares.where((s) => s.id != share.id).toList();
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../models/sync_status.dart';
|
import '../models/sync_status.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
|
import 'files_controller.dart';
|
||||||
import 'session_controller.dart';
|
import 'session_controller.dart';
|
||||||
|
|
||||||
/// Device-sync state and settings: which remote paths (files or folders,
|
/// Device-sync state and settings: which remote paths (files or folders,
|
||||||
@@ -16,13 +17,37 @@ import 'session_controller.dart';
|
|||||||
/// (`syncStatusFor`) despite living in an unrelated domain; now it's a
|
/// (`syncStatusFor`) despite living in an unrelated domain; now it's a
|
||||||
/// second, focused provider those views read alongside their own tab
|
/// second, focused provider those views read alongside their own tab
|
||||||
/// controller.
|
/// controller.
|
||||||
class SyncStatusController extends ChangeNotifier {
|
///
|
||||||
|
/// **Keeping synced files current.** Synced folders/files follow the same
|
||||||
|
/// Files Cache rule (Settings) as folder listings, so nothing needs the
|
||||||
|
/// "Sync now" button:
|
||||||
|
///
|
||||||
|
/// - *Refresh periodically (N min)*: a WorkManager periodic job runs in the
|
||||||
|
/// background at that interval (Android's floor is 15 min, so shorter
|
||||||
|
/// values only apply in the foreground), and while the app's open an
|
||||||
|
/// in-app timer runs a pass every N minutes plus one on resume if it's
|
||||||
|
/// been longer than that.
|
||||||
|
/// - *Never cache*: no background schedule; a pass runs each time the app
|
||||||
|
/// is opened/resumed.
|
||||||
|
/// - *Refresh manually only*: only pull-to-refresh (any tab's sync header)
|
||||||
|
/// and "Sync now".
|
||||||
|
///
|
||||||
|
/// Every automatic pass is cheap when nothing changed - the native worker
|
||||||
|
/// checks each synced root's etag (Nextcloud propagates any descendant
|
||||||
|
/// change up to every ancestor's etag) and skips the full walk if it's
|
||||||
|
/// unchanged and the local copy is intact. There's no server push: Nextcloud's
|
||||||
|
/// only push channels (the `notify_push` server app, or FCM via Nextcloud's
|
||||||
|
/// push proxy) need server-side setup and a persistent connection/registered
|
||||||
|
/// app identity, so this polls instead.
|
||||||
|
class SyncStatusController extends ChangeNotifier with WidgetsBindingObserver {
|
||||||
final SessionController session;
|
final SessionController session;
|
||||||
|
final FilesController files;
|
||||||
|
|
||||||
static const _prefSyncedPaths = 'ui_synced_folders';
|
static const prefSyncedPaths = 'ui_synced_folders';
|
||||||
static const _prefSyncedPathTypes = 'ui_synced_folder_types';
|
static const _prefSyncedPathTypes = 'ui_synced_folder_types';
|
||||||
static const _prefSyncEverything = 'ui_sync_everything';
|
static const prefSyncEverything = 'ui_sync_everything';
|
||||||
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
|
static const _prefSyncOnCellular = 'ui_sync_on_cellular';
|
||||||
|
static const _prefSyncNotifications = 'ui_sync_notifications';
|
||||||
|
|
||||||
List<String> _syncedPaths = [];
|
List<String> _syncedPaths = [];
|
||||||
// path -> isFolder, keyed the same as [_syncedPaths] - missing entries
|
// path -> isFolder, keyed the same as [_syncedPaths] - missing entries
|
||||||
@@ -31,6 +56,7 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
Map<String, bool> _syncedPathTypes = {};
|
Map<String, bool> _syncedPathTypes = {};
|
||||||
bool _syncEverything = false;
|
bool _syncEverything = false;
|
||||||
bool _syncOnCellular = false;
|
bool _syncOnCellular = false;
|
||||||
|
bool _syncNotifications = true;
|
||||||
|
|
||||||
bool _isSyncingNow = false;
|
bool _isSyncingNow = false;
|
||||||
Set<String> _syncingFileIds = {};
|
Set<String> _syncingFileIds = {};
|
||||||
@@ -38,9 +64,45 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
List<SyncConflictInfo> _syncConflicts = [];
|
List<SyncConflictInfo> _syncConflicts = [];
|
||||||
StreamSubscription<SyncStatusSnapshot>? _statusSub;
|
StreamSubscription<SyncStatusSnapshot>? _statusSub;
|
||||||
|
|
||||||
SyncStatusController(this.session) {
|
// Gates addSyncedPaths/removeSyncedPaths (anything that persists
|
||||||
|
// _syncedPaths) against running before _onAccountActivated's async
|
||||||
|
// prefs load has actually finished - without this, syncing a folder
|
||||||
|
// right after opening the app could race the load, overwrite the
|
||||||
|
// persisted list with just the one path being added/removed, and
|
||||||
|
// silently destroy every previously-synced folder. Deliberately starts
|
||||||
|
// *incomplete*, not pre-completed - a mutation is only ever reachable
|
||||||
|
// from Files/Offline UI, which requires a completed activation already
|
||||||
|
// (Files' own item list depends on the same login event), so there's no
|
||||||
|
// real deadlock risk, and starting complete would leave the very first
|
||||||
|
// cold-start load completely unprotected (only account *switches*
|
||||||
|
// re-arm it, via _onAccountCleared - the bug that actually caused
|
||||||
|
// repeated data loss here).
|
||||||
|
Completer<void> _accountLoadedGate = Completer<void>();
|
||||||
|
|
||||||
|
// True once _syncedPaths/_syncedPathTypes/_syncEverything have been
|
||||||
|
// loaded from prefs for the *current* account - _onAccountActivated can
|
||||||
|
// fire more than once per account (e.g. SessionController re-verifying
|
||||||
|
// a provisional/offline login once connectivity returns), and a second
|
||||||
|
// pass re-reading from storage could clobber an in-memory mutation made
|
||||||
|
// between the first load and the second one if its own persist hadn't
|
||||||
|
// landed yet. Reset in _onAccountCleared.
|
||||||
|
bool _hasLoadedSyncedPathsForAccount = false;
|
||||||
|
|
||||||
|
Timer? _autoSyncTimer;
|
||||||
|
bool _foreground = true;
|
||||||
|
DateTime? _lastAutoSyncAt;
|
||||||
|
CachePolicy? _lastPolicy;
|
||||||
|
int? _lastInterval;
|
||||||
|
|
||||||
|
SyncStatusController(this.session, this.files) {
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
files.addListener(_onFilesChanged);
|
||||||
session.addAccountClearedListener(_onAccountCleared);
|
session.addAccountClearedListener(_onAccountCleared);
|
||||||
session.addAccountActivatedListener(_onAccountActivated);
|
// `ready`, not `activated` - this controller's own activation work
|
||||||
|
// (loading prefs, calling the native sync channel) is local/native
|
||||||
|
// only, so it's safe to run even on a provisional/offline login (see
|
||||||
|
// `SessionController.addAccountReadyListener`'s doc comment).
|
||||||
|
session.addAccountReadyListener(_onAccountActivated);
|
||||||
_statusSub = SyncService.statusStream.listen(_applySnapshot);
|
_statusSub = SyncService.statusStream.listen(_applySnapshot);
|
||||||
_loadGlobalPrefs();
|
_loadGlobalPrefs();
|
||||||
}
|
}
|
||||||
@@ -49,7 +111,21 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
bool get syncEverything => _syncEverything;
|
bool get syncEverything => _syncEverything;
|
||||||
bool get syncOnCellular => _syncOnCellular;
|
bool get syncOnCellular => _syncOnCellular;
|
||||||
|
|
||||||
|
/// Whether background (automatic) sync runs may post progress/summary
|
||||||
|
/// notifications. Conflicts and user-initiated "Sync now" always notify.
|
||||||
|
bool get syncNotifications => _syncNotifications;
|
||||||
|
|
||||||
bool get isSyncingNow => _isSyncingNow;
|
bool get isSyncingNow => _isSyncingNow;
|
||||||
|
|
||||||
|
bool get _hasSyncScope => _syncEverything || _syncedPaths.isNotEmpty;
|
||||||
|
|
||||||
|
/// How often the background WorkManager job should run, or null for no
|
||||||
|
/// background sync at all - only the "refresh periodically" Files Cache
|
||||||
|
/// policy schedules one (see the class doc comment).
|
||||||
|
int? get backgroundSyncIntervalMinutes =>
|
||||||
|
files.cachePolicy == CachePolicy.interval
|
||||||
|
? files.cacheIntervalMinutes
|
||||||
|
: null;
|
||||||
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
|
List<SyncConflictInfo> get syncConflicts => List.unmodifiable(_syncConflicts);
|
||||||
|
|
||||||
/// How many of [syncedPaths] are folders (as opposed to individual
|
/// How many of [syncedPaths] are folders (as opposed to individual
|
||||||
@@ -57,11 +133,6 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
int get syncedFolderCount =>
|
int get syncedFolderCount =>
|
||||||
_syncedPaths.where((p) => _syncedPathTypes[p] ?? true).length;
|
_syncedPaths.where((p) => _syncedPathTypes[p] ?? true).length;
|
||||||
|
|
||||||
/// How many individual files have actually been mirrored locally so far
|
|
||||||
/// - distinct from [syncedFolderCount]/[syncedPaths], which are just the
|
|
||||||
/// configured *targets*, not what's actually landed on disk yet.
|
|
||||||
int get syncedItemCount => _syncedFileIds.length;
|
|
||||||
|
|
||||||
SyncHeaderStatus get syncHeaderStatus {
|
SyncHeaderStatus get syncHeaderStatus {
|
||||||
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
|
if (_syncConflicts.isNotEmpty) return SyncHeaderStatus.alert;
|
||||||
if (_isSyncingNow) return SyncHeaderStatus.syncing;
|
if (_isSyncingNow) return SyncHeaderStatus.syncing;
|
||||||
@@ -121,10 +192,21 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
Future<void> _loadGlobalPrefs() async {
|
Future<void> _loadGlobalPrefs() async {
|
||||||
final prefs = await session.prefsFuture;
|
final prefs = await session.prefsFuture;
|
||||||
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
|
_syncOnCellular = prefs.getBool(_prefSyncOnCellular) ?? _syncOnCellular;
|
||||||
|
_syncNotifications =
|
||||||
|
prefs.getBool(_prefSyncNotifications) ?? _syncNotifications;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAccountCleared() {
|
void _onAccountCleared() {
|
||||||
|
// Re-armed (not completed) here, not just at construction - an
|
||||||
|
// account switch means the *next* activation's load has to finish
|
||||||
|
// before any mutation touching _syncedPaths is safe again. Only
|
||||||
|
// replace the Completer if nothing's still awaiting the old one -
|
||||||
|
// swapping it out from under a pending awaiter would orphan that
|
||||||
|
// await forever, since nothing would ever complete the discarded
|
||||||
|
// instance.
|
||||||
|
if (_accountLoadedGate.isCompleted) _accountLoadedGate = Completer<void>();
|
||||||
|
_hasLoadedSyncedPathsForAccount = false;
|
||||||
_syncedPaths = [];
|
_syncedPaths = [];
|
||||||
_syncedPathTypes = {};
|
_syncedPathTypes = {};
|
||||||
_syncEverything = false;
|
_syncEverything = false;
|
||||||
@@ -132,47 +214,175 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
_syncingFileIds = {};
|
_syncingFileIds = {};
|
||||||
_syncedFileIds = {};
|
_syncedFileIds = {};
|
||||||
_syncConflicts = [];
|
_syncConflicts = [];
|
||||||
|
_autoSyncTimer?.cancel();
|
||||||
|
_lastAutoSyncAt = null;
|
||||||
|
_lastPolicy = null;
|
||||||
|
_lastInterval = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAccountActivated() async {
|
Future<void> _onAccountActivated() async {
|
||||||
final id = session.activeAccountId;
|
final id = session.activeAccountId;
|
||||||
if (id != null) {
|
// Only load _syncedPaths/_syncedPathTypes/_syncEverything once per
|
||||||
final prefs = await session.prefsFuture;
|
// account, not on every activation - this can fire more than once
|
||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
// for the same account (e.g. SessionController re-verifying a
|
||||||
final pathsJson = prefs.getString(k(_prefSyncedPaths));
|
// provisional/offline login once connectivity returns), and a second
|
||||||
if (pathsJson != null) {
|
// read from storage could clobber an in-memory mutation made between
|
||||||
try {
|
// the first load and this one if its own persist hadn't landed yet.
|
||||||
_syncedPaths = (jsonDecode(pathsJson) as List).cast<String>();
|
// See _hasLoadedSyncedPathsForAccount's doc comment.
|
||||||
} catch (e) {
|
if (id != null && !_hasLoadedSyncedPathsForAccount) {
|
||||||
debugPrint('[SyncStatusController] Synced paths restore failed: $e');
|
_hasLoadedSyncedPathsForAccount = true;
|
||||||
|
// Wrapped so a single bad/mistyped stored pref value can't
|
||||||
|
// propagate uncaught out of this method and skip the gate
|
||||||
|
// completion below - that would leave every future
|
||||||
|
// addSyncedPaths/removeSyncedPaths call awaiting a gate that never
|
||||||
|
// completes, hanging forever. 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);
|
||||||
|
final pathsJson = prefs.getString(k(prefSyncedPaths));
|
||||||
|
if (pathsJson != null) {
|
||||||
|
try {
|
||||||
|
_syncedPaths = (jsonDecode(pathsJson) as List).cast<String>();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncStatusController] Synced paths restore failed: $e');
|
||||||
|
_syncedPaths = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
_syncedPaths = [];
|
_syncedPaths = [];
|
||||||
}
|
}
|
||||||
} else {
|
final typesJson = prefs.getString(k(_prefSyncedPathTypes));
|
||||||
_syncedPaths = [];
|
if (typesJson != null) {
|
||||||
}
|
try {
|
||||||
final typesJson = prefs.getString(k(_prefSyncedPathTypes));
|
_syncedPathTypes = (jsonDecode(typesJson) as Map).map(
|
||||||
if (typesJson != null) {
|
(k, v) => MapEntry(k as String, v as bool),
|
||||||
try {
|
);
|
||||||
_syncedPathTypes = (jsonDecode(typesJson) as Map).map(
|
} catch (e) {
|
||||||
(k, v) => MapEntry(k as String, v as bool),
|
debugPrint('[SyncStatusController] Synced path types restore failed: $e');
|
||||||
);
|
_syncedPathTypes = {};
|
||||||
} catch (e) {
|
}
|
||||||
debugPrint('[SyncStatusController] Synced path types restore failed: $e');
|
} else {
|
||||||
_syncedPathTypes = {};
|
_syncedPathTypes = {};
|
||||||
}
|
}
|
||||||
} else {
|
_syncEverything = prefs.getBool(k(prefSyncEverything)) ?? false;
|
||||||
_syncedPathTypes = {};
|
notifyListeners();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncStatusController] Account-activation prefs restore failed: $e');
|
||||||
}
|
}
|
||||||
_syncEverything = prefs.getBool(k(_prefSyncEverything)) ?? false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
// Only now is it safe for addSyncedPaths/removeSyncedPaths to mutate
|
||||||
|
// and persist _syncedPaths - see _accountLoadedGate's doc comment.
|
||||||
|
if (!_accountLoadedGate.isCompleted) _accountLoadedGate.complete();
|
||||||
|
unawaited(SyncService.getStatus(id).then(_applySnapshot));
|
||||||
|
|
||||||
|
// Scheduling depends on the Files Cache policy, so wait for it to load
|
||||||
|
// rather than scheduling from the defaults and correcting a moment later.
|
||||||
|
await files.displayPrefsLoaded;
|
||||||
|
_lastPolicy = files.cachePolicy;
|
||||||
|
_lastInterval = files.cacheIntervalMinutes;
|
||||||
unawaited(SyncService.reschedule(session, this));
|
unawaited(SyncService.reschedule(session, this));
|
||||||
unawaited(SyncService.getStatus().then(_applySnapshot));
|
_restartAutoSyncTimer();
|
||||||
|
if (_dueForAutoSync) unawaited(_autoSync());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Automatic sync (see the class doc comment) ----
|
||||||
|
|
||||||
|
void _onFilesChanged() {
|
||||||
|
// FilesController notifies on every navigation - only the cache rule
|
||||||
|
// itself matters here.
|
||||||
|
if (_lastPolicy == null) return; // account not activated yet
|
||||||
|
final policy = files.cachePolicy;
|
||||||
|
final interval = files.cacheIntervalMinutes;
|
||||||
|
if (policy == _lastPolicy && interval == _lastInterval) return;
|
||||||
|
_lastPolicy = policy;
|
||||||
|
_lastInterval = interval;
|
||||||
|
unawaited(SyncService.reschedule(session, this));
|
||||||
|
_restartAutoSyncTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an app-open/resume should kick off a pass under the current
|
||||||
|
/// cache rule.
|
||||||
|
bool get _dueForAutoSync {
|
||||||
|
switch (files.cachePolicy) {
|
||||||
|
case CachePolicy.never:
|
||||||
|
return true;
|
||||||
|
case CachePolicy.manual:
|
||||||
|
return false;
|
||||||
|
case CachePolicy.interval:
|
||||||
|
final last = _lastAutoSyncAt;
|
||||||
|
return last == null ||
|
||||||
|
DateTime.now().difference(last) >=
|
||||||
|
Duration(minutes: files.cacheIntervalMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restartAutoSyncTimer() {
|
||||||
|
_autoSyncTimer?.cancel();
|
||||||
|
if (!_foreground ||
|
||||||
|
!_hasSyncScope ||
|
||||||
|
files.cachePolicy != CachePolicy.interval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_autoSyncTimer = Timer.periodic(
|
||||||
|
Duration(minutes: files.cacheIntervalMinutes),
|
||||||
|
(_) => unawaited(_autoSync()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quiet, network-setting-respecting pass - see [SyncService.syncNow].
|
||||||
|
Future<void> _autoSync() async {
|
||||||
|
if (!_canSyncQuietly) return;
|
||||||
|
_lastAutoSyncAt = DateTime.now();
|
||||||
|
try {
|
||||||
|
await SyncService.syncNow(
|
||||||
|
session,
|
||||||
|
this,
|
||||||
|
force: false,
|
||||||
|
respectNetworkSetting: true,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncStatusController] Automatic sync failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deliberate pull-to-refresh (any tab's sync header): check for server
|
||||||
|
/// changes now, regardless of the cache rule or the Wi-Fi-only setting.
|
||||||
|
Future<void> syncOnPull() async {
|
||||||
|
if (!_canSyncQuietly) return;
|
||||||
|
try {
|
||||||
|
await SyncService.syncNow(session, this, force: false);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncStatusController] Pull-triggered sync failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canSyncQuietly =>
|
||||||
|
_hasSyncScope &&
|
||||||
|
!_isSyncingNow &&
|
||||||
|
session.isLoggedIn &&
|
||||||
|
!session.connectivity.isOffline;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_foreground = true;
|
||||||
|
_restartAutoSyncTimer();
|
||||||
|
if (_dueForAutoSync) unawaited(_autoSync());
|
||||||
|
} else if (state == AppLifecycleState.paused) {
|
||||||
|
// The in-app timer only runs while foregrounded; WorkManager owns the
|
||||||
|
// background schedule.
|
||||||
|
_foreground = false;
|
||||||
|
_autoSyncTimer?.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _applySnapshot(SyncStatusSnapshot snapshot) {
|
void _applySnapshot(SyncStatusSnapshot snapshot) {
|
||||||
if (snapshot.accountId != null &&
|
// A null accountId is the bus's initial "nothing has synced in this
|
||||||
|
// process yet" emission on subscribe - applying it would wipe the
|
||||||
|
// synced-file ids seeded by getStatus(accountId).
|
||||||
|
if (snapshot.accountId == null ||
|
||||||
snapshot.accountId != session.activeAccountId) {
|
snapshot.accountId != session.activeAccountId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -181,6 +391,11 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
_syncedFileIds = snapshot.syncedFileIds;
|
_syncedFileIds = snapshot.syncedFileIds;
|
||||||
_syncConflicts = snapshot.conflicts;
|
_syncConflicts = snapshot.conflicts;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
|
// A synced file/folder deleted on the server has nothing left to sync -
|
||||||
|
// drop it from the synced list rather than leaving a dead entry.
|
||||||
|
final gone = snapshot.missingRoots.where(_syncedPaths.contains).toList();
|
||||||
|
if (gone.isNotEmpty) unawaited(removeSyncedPaths(gone));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _persistSyncedPaths() {
|
void _persistSyncedPaths() {
|
||||||
@@ -188,7 +403,7 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
session.prefsFuture.then((p) {
|
session.prefsFuture.then((p) {
|
||||||
p.setString(
|
p.setString(
|
||||||
session.accountStore.accountPrefKey(id, _prefSyncedPaths),
|
session.accountStore.accountPrefKey(id, prefSyncedPaths),
|
||||||
jsonEncode(_syncedPaths),
|
jsonEncode(_syncedPaths),
|
||||||
);
|
);
|
||||||
p.setString(
|
p.setString(
|
||||||
@@ -200,26 +415,74 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
|
|
||||||
bool isPathSynced(String path) => _syncedPaths.contains(path);
|
bool isPathSynced(String path) => _syncedPaths.contains(path);
|
||||||
|
|
||||||
void addSyncedPath(String path, {required bool isFolder}) {
|
Future<void> addSyncedPath(String path, {required bool isFolder}) {
|
||||||
if (_syncedPaths.contains(path)) return;
|
return addSyncedPaths({path: isFolder});
|
||||||
_syncedPaths = [..._syncedPaths, path];
|
}
|
||||||
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
|
|
||||||
|
/// Adds every path in [entries] (path -> isFolder) to sync in one go -
|
||||||
|
/// unlike calling [addSyncedPath] once per item in a loop, this touches
|
||||||
|
/// `_syncedPaths`/persists/reschedules/triggers an immediate sync
|
||||||
|
/// exactly once no matter how many paths are being added. Looping
|
||||||
|
/// `addSyncedPath` from a multi-select "Sync to device" was the actual
|
||||||
|
/// bug behind only the first selected item ever actually syncing: each
|
||||||
|
/// call fired its own `SyncService.syncNow`, and WorkManager's
|
||||||
|
/// `enqueueUniqueWork(..., ExistingWorkPolicy.REPLACE, ...)` policy
|
||||||
|
/// meant each later call cancelled the previous item's still-in-flight
|
||||||
|
/// sync pass (and could stomp on its `SyncEngine.saveState`, which
|
||||||
|
/// overwrites the whole state map rather than merging) instead of
|
||||||
|
/// letting it finish.
|
||||||
|
Future<void> addSyncedPaths(Map<String, bool> entries) async {
|
||||||
|
// Must not mutate/persist _syncedPaths before the per-account load
|
||||||
|
// has actually populated it - see _accountLoadedGate's doc comment.
|
||||||
|
await _accountLoadedGate.future;
|
||||||
|
var changed = false;
|
||||||
|
entries.forEach((path, isFolder) {
|
||||||
|
if (_syncedPaths.contains(path)) return;
|
||||||
|
_syncedPaths = [..._syncedPaths, path];
|
||||||
|
_syncedPathTypes = {..._syncedPathTypes, path: isFolder};
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
if (!changed) return;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistSyncedPaths();
|
_persistSyncedPaths();
|
||||||
unawaited(SyncService.reschedule(session, this));
|
unawaited(SyncService.reschedule(session, this));
|
||||||
|
_restartAutoSyncTimer();
|
||||||
|
// `reschedule` alone only lines up the *periodic* job, which can be up
|
||||||
|
// to an hour away from its first run - without an immediate one-off
|
||||||
|
// sync here too, newly-added items would just sit unsynced until the
|
||||||
|
// user thought to trigger one manually (e.g. pulling to refresh on
|
||||||
|
// the Offline tab).
|
||||||
|
unawaited(_syncNowSilently());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unsyncs [path] and deletes its already-downloaded local mirror (see
|
/// Unsyncs [path] and deletes its already-downloaded local mirror (see
|
||||||
/// `SyncService.removeLocalSync`) - stopping sync alone would leave
|
/// `SyncService.removeLocalSync`) - stopping sync alone would leave
|
||||||
/// whatever had already been downloaded sitting on disk indefinitely.
|
/// whatever had already been downloaded sitting on disk indefinitely.
|
||||||
void removeSyncedPath(String path) {
|
Future<void> removeSyncedPath(String path) {
|
||||||
if (!_syncedPaths.contains(path)) return;
|
return removeSyncedPaths([path]);
|
||||||
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
|
}
|
||||||
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
|
|
||||||
|
/// Batched counterpart to [removeSyncedPath] - see [addSyncedPaths]'s
|
||||||
|
/// doc comment for why a multi-item loop of individual calls is unsafe
|
||||||
|
/// for `reschedule`/native side effects, even though removal itself
|
||||||
|
/// (unlike adding) doesn't trigger a one-off sync to race.
|
||||||
|
Future<void> removeSyncedPaths(List<String> paths) async {
|
||||||
|
await _accountLoadedGate.future;
|
||||||
|
var changed = false;
|
||||||
|
for (final path in paths) {
|
||||||
|
if (!_syncedPaths.contains(path)) continue;
|
||||||
|
_syncedPaths = _syncedPaths.where((f) => f != path).toList();
|
||||||
|
_syncedPathTypes = {..._syncedPathTypes}..remove(path);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistSyncedPaths();
|
_persistSyncedPaths();
|
||||||
unawaited(SyncService.reschedule(session, this));
|
unawaited(SyncService.reschedule(session, this));
|
||||||
unawaited(SyncService.removeLocalSync(session, path));
|
_restartAutoSyncTimer();
|
||||||
|
for (final path in paths) {
|
||||||
|
unawaited(SyncService.removeLocalSync(session, path));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSyncEverything(bool value) {
|
void setSyncEverything(bool value) {
|
||||||
@@ -230,12 +493,24 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
if (id != null) {
|
if (id != null) {
|
||||||
session.prefsFuture.then(
|
session.prefsFuture.then(
|
||||||
(p) => p.setBool(
|
(p) => p.setBool(
|
||||||
session.accountStore.accountPrefKey(id, _prefSyncEverything),
|
session.accountStore.accountPrefKey(id, prefSyncEverything),
|
||||||
value,
|
value,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
unawaited(SyncService.reschedule(session, this));
|
unawaited(SyncService.reschedule(session, this));
|
||||||
|
_restartAutoSyncTimer();
|
||||||
|
if (value) unawaited(_syncNowSilently());
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSyncNotifications(bool value) {
|
||||||
|
if (_syncNotifications == value) return;
|
||||||
|
_syncNotifications = value;
|
||||||
|
notifyListeners();
|
||||||
|
session.prefsFuture.then((p) => p.setBool(_prefSyncNotifications, value));
|
||||||
|
// Baked into each periodic job's input, so the jobs have to be
|
||||||
|
// re-enqueued for the change to reach background runs.
|
||||||
|
unawaited(SyncService.reschedule(session, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setSyncOnCellular(bool value) {
|
void setSyncOnCellular(bool value) {
|
||||||
@@ -246,6 +521,18 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
unawaited(SyncService.reschedule(session, this));
|
unawaited(SyncService.reschedule(session, this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget immediate sync, swallowing errors - called right
|
||||||
|
/// after turning sync on for something (see [addSyncedPath]/
|
||||||
|
/// [setSyncEverything]) so it actually starts syncing now instead of
|
||||||
|
/// only ever running on the next periodic pass or a manual "Sync now".
|
||||||
|
Future<void> _syncNowSilently() async {
|
||||||
|
try {
|
||||||
|
await SyncService.syncNow(session, this);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncStatusController] Immediate sync failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// In-app conflict resolution (the sync header's "Keep local"/"Use
|
/// In-app conflict resolution (the sync header's "Keep local"/"Use
|
||||||
/// server" buttons) - see `SyncService.resolveConflict`'s doc comment
|
/// server" buttons) - see `SyncService.resolveConflict`'s doc comment
|
||||||
/// for why this shares the exact same native path the notification
|
/// for why this shares the exact same native path the notification
|
||||||
@@ -277,6 +564,9 @@ class SyncStatusController extends ChangeNotifier {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
files.removeListener(_onFilesChanged);
|
||||||
|
_autoSyncTimer?.cancel();
|
||||||
_statusSub?.cancel();
|
_statusSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AccountStore {
|
|||||||
static const _prefAccountsList = 'accounts_list';
|
static const _prefAccountsList = 'accounts_list';
|
||||||
static const _prefActiveAccountId = 'active_account_id';
|
static const _prefActiveAccountId = 'active_account_id';
|
||||||
static const _prefMigrationDone = 'account_migration_v1_done';
|
static const _prefMigrationDone = 'account_migration_v1_done';
|
||||||
|
static const _prefSignedOutAccounts = 'signed_out_account_ids';
|
||||||
|
|
||||||
/// The per-account browsing prefs that get namespaced under
|
/// The per-account browsing prefs that get namespaced under
|
||||||
/// `acct_<id>_<key>` on migration/save - everything else (theme, tab
|
/// `acct_<id>_<key>` on migration/save - everything else (theme, tab
|
||||||
@@ -38,6 +39,7 @@ class AccountStore {
|
|||||||
'ui_cache_policy',
|
'ui_cache_policy',
|
||||||
'ui_cache_interval_minutes',
|
'ui_cache_interval_minutes',
|
||||||
'ui_synced_folders',
|
'ui_synced_folders',
|
||||||
|
'ui_synced_folder_types',
|
||||||
'ui_sync_everything',
|
'ui_sync_everything',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -129,6 +131,24 @@ class AccountStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accounts the user explicitly logged out of. They stay saved (password
|
||||||
|
/// and prefs intact, resumable from the login screen) but are treated as
|
||||||
|
/// signed out - notably, they don't sync in the background. Cleared again
|
||||||
|
/// the moment the account is activated.
|
||||||
|
Set<String> loadSignedOut(SharedPreferences prefs) =>
|
||||||
|
(prefs.getStringList(_prefSignedOutAccounts) ?? const []).toSet();
|
||||||
|
|
||||||
|
Future<void> setSignedOut(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String accountId,
|
||||||
|
bool signedOut,
|
||||||
|
) async {
|
||||||
|
final current = loadSignedOut(prefs);
|
||||||
|
final changed = signedOut ? current.add(accountId) : current.remove(accountId);
|
||||||
|
if (!changed) return;
|
||||||
|
await prefs.setStringList(_prefSignedOutAccounts, current.toList());
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> readPassword(String accountId) =>
|
Future<String?> readPassword(String accountId) =>
|
||||||
_storage.read(key: '$_passwordKeyPrefix$accountId');
|
_storage.read(key: '$_passwordKeyPrefix$accountId');
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ DateTime? _parseDavDate(String? raw) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nextcloud's WebDAV server has no real per-file creation-time tracking
|
||||||
|
/// for most setups, so its `creationdate` property routinely comes back as
|
||||||
|
/// a placeholder Unix-epoch date ("Thu, 01 Jan 1970 00:00:00 GMT") instead
|
||||||
|
/// of being omitted - [_parseDavDate] parses that "successfully" into a
|
||||||
|
/// real (if bogus) `DateTime`, so every item's created date read January
|
||||||
|
/// 1970. Treat it as absent instead, the same as a missing/unparseable
|
||||||
|
/// value: `NextcloudItem`'s constructor then falls back to `lastModified`.
|
||||||
|
DateTime? _parseDavCreationDate(String? raw) {
|
||||||
|
final parsed = _parseDavDate(raw);
|
||||||
|
if (parsed != null && parsed.millisecondsSinceEpoch <= 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
/// The OCS Activity API reports each event's time as an ISO 8601 string in
|
/// The OCS Activity API reports each event's time as an ISO 8601 string in
|
||||||
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
|
/// a `datetime` field (e.g. "2025-09-15T12:34:56+00:00") - there is no
|
||||||
/// numeric `timestamp` field despite that being a very easy name to guess.
|
/// numeric `timestamp` field despite that being a very easy name to guess.
|
||||||
@@ -356,7 +369,7 @@ class NextcloudService {
|
|||||||
|
|
||||||
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
final created = _parseDavDate(createdStr);
|
final created = _parseDavCreationDate(createdStr);
|
||||||
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
|
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
|
||||||
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||||
|
|
||||||
@@ -671,7 +684,7 @@ class NextcloudService {
|
|||||||
|
|
||||||
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
final created = _parseDavDate(createdStr);
|
final created = _parseDavCreationDate(createdStr);
|
||||||
final itemType = NextcloudItem.deduceType(name, false, mimeType);
|
final itemType = NextcloudItem.deduceType(name, false, mimeType);
|
||||||
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||||
|
|
||||||
@@ -835,7 +848,7 @@ class NextcloudService {
|
|||||||
|
|
||||||
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
final created = _parseDavDate(createdStr);
|
final created = _parseDavCreationDate(createdStr);
|
||||||
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
|
final itemType = NextcloudItem.deduceType(name, isCollection, mimeType);
|
||||||
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||||
|
|
||||||
@@ -1000,7 +1013,7 @@ class NextcloudService {
|
|||||||
|
|
||||||
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
final size = int.tryParse(sizeStr ?? '0') ?? 0;
|
||||||
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
final lastMod = _parseDavDate(lastModStr) ?? DateTime.now();
|
||||||
final created = _parseDavDate(createdStr);
|
final created = _parseDavCreationDate(createdStr);
|
||||||
final itemType = NextcloudItem.deduceType(name, false, mimeType);
|
final itemType = NextcloudItem.deduceType(name, false, mimeType);
|
||||||
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name;
|
||||||
|
|
||||||
@@ -1458,28 +1471,17 @@ class NextcloudService {
|
|||||||
final rawList = data['ocs']?['data'];
|
final rawList = data['ocs']?['data'];
|
||||||
if (rawList is! List) return [];
|
if (rawList is! List) return [];
|
||||||
|
|
||||||
return rawList.map<NextcloudShare>((raw) {
|
// Shares out to `_shareFromJson` (the same parser `fetchSharesForPath`/
|
||||||
final path = (raw['path'] ?? '/').toString();
|
// `fetchInheritedShares` use) rather than its own duplicated, thinner
|
||||||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
// inline parsing - that copy never set `url`/`token`/`permissions`/
|
||||||
final name = segments.isNotEmpty ? segments.last : path;
|
// `expireDate` at all, so a public-link share always came back with
|
||||||
final isFolder = (raw['item_type'] ?? '').toString() == 'folder';
|
// `url: null` here and the Shares tab's own "Copy link" action could
|
||||||
final mimeType = raw['mimetype'] as String?;
|
// never show up for it.
|
||||||
|
return rawList
|
||||||
return NextcloudShare(
|
.map<NextcloudShare>(
|
||||||
id: (raw['id'] ?? '').toString(),
|
(raw) => _shareFromJson(raw, sharedWithMe: sharedWithMe),
|
||||||
path: path,
|
)
|
||||||
name: name,
|
.toList();
|
||||||
itemType: NextcloudItem.deduceType(name, isFolder, mimeType),
|
|
||||||
shareType: _mapShareType((raw['share_type'] as num?)?.toInt() ?? -1),
|
|
||||||
ownerDisplayName: (raw['displayname_owner'] ?? raw['uid_owner'] ?? '')
|
|
||||||
.toString(),
|
|
||||||
sharedWithDisplayName: raw['share_with_displayname'] as String?,
|
|
||||||
sharedAt: DateTime.fromMillisecondsSinceEpoch(
|
|
||||||
((raw['stime'] as num?)?.toInt() ?? 0) * 1000,
|
|
||||||
),
|
|
||||||
sharedWithMe: sharedWithMe,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a public link share for [path] and returns its share URL.
|
/// Creates a public link share for [path] and returns its share URL.
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../models/sync_status.dart';
|
import '../models/sync_status.dart';
|
||||||
|
import '../models/saved_account.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
import '../providers/sync_status_controller.dart';
|
import '../providers/sync_status_controller.dart';
|
||||||
|
|
||||||
@@ -16,12 +20,17 @@ class SyncStatusSnapshot {
|
|||||||
final Set<String> syncedFileIds;
|
final Set<String> syncedFileIds;
|
||||||
final List<SyncConflictInfo> conflicts;
|
final List<SyncConflictInfo> conflicts;
|
||||||
|
|
||||||
|
/// Configured sync paths the server says no longer exist (deleted on the
|
||||||
|
/// web) - the app drops them from its synced list.
|
||||||
|
final Set<String> missingRoots;
|
||||||
|
|
||||||
const SyncStatusSnapshot({
|
const SyncStatusSnapshot({
|
||||||
required this.accountId,
|
required this.accountId,
|
||||||
required this.syncing,
|
required this.syncing,
|
||||||
required this.syncingFileIds,
|
required this.syncingFileIds,
|
||||||
required this.syncedFileIds,
|
required this.syncedFileIds,
|
||||||
required this.conflicts,
|
required this.conflicts,
|
||||||
|
this.missingRoots = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
|
factory SyncStatusSnapshot.fromMap(Map<dynamic, dynamic> map) {
|
||||||
@@ -38,6 +47,9 @@ class SyncStatusSnapshot {
|
|||||||
.cast<Map<dynamic, dynamic>>()
|
.cast<Map<dynamic, dynamic>>()
|
||||||
.map(SyncConflictInfo.fromMap)
|
.map(SyncConflictInfo.fromMap)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
missingRoots: ((map['missingRoots'] as List?) ?? const [])
|
||||||
|
.cast<String>()
|
||||||
|
.toSet(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,42 +68,89 @@ class SyncService {
|
|||||||
'dev.ayushya.noo/sync_service/status',
|
'dev.ayushya.noo/sync_service/status',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Cancels (if [SyncStatusController.syncedPaths] is now empty) or
|
/// Brings the background periodic sync jobs in line with every saved
|
||||||
/// re-enqueues the periodic sync job with fresh account/credentials/
|
/// account's current settings - one job per account with sync enabled
|
||||||
/// path-list/network-constraint data - call this any time one of those
|
/// (paths or "sync everything", and the Files Cache policy set to refresh
|
||||||
/// changes, since a periodic `WorkRequest`'s input is fixed at enqueue
|
/// periodically), cancelled for accounts without and for accounts the
|
||||||
/// time and can only be updated by re-enqueueing.
|
/// user logged out of. Background sync is deliberately not limited to the
|
||||||
|
/// active account: each account's job carries that account's own
|
||||||
|
/// credentials, paths and interval, so switching accounts doesn't stop the
|
||||||
|
/// others.
|
||||||
|
///
|
||||||
|
/// Call whenever any of that changes - a periodic `WorkRequest`'s input is
|
||||||
|
/// fixed at enqueue time and can only be updated by re-enqueueing. The
|
||||||
|
/// active account's settings come from [sync]/its live state (they may not
|
||||||
|
/// have been persisted yet); every other account's are read from storage.
|
||||||
static Future<void> reschedule(
|
static Future<void> reschedule(
|
||||||
SessionController session,
|
SessionController session,
|
||||||
SyncStatusController sync,
|
SyncStatusController sync,
|
||||||
) async {
|
) async {
|
||||||
final accountId = session.activeAccountId;
|
final prefs = await session.prefsFuture;
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
final store = session.accountStore;
|
||||||
if (accountId == null || authHeader == null) {
|
final wifiOnly = !sync.syncOnCellular;
|
||||||
return cancel();
|
final signedOut = store.loadSignedOut(prefs);
|
||||||
|
|
||||||
|
for (final account in session.accounts) {
|
||||||
|
try {
|
||||||
|
if (signedOut.contains(account.id)) {
|
||||||
|
await cancelAccount(account.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final isActive = account.id == session.activeAccountId;
|
||||||
|
final config = isActive
|
||||||
|
? _SyncConfig(
|
||||||
|
paths: sync.syncEverything ? ['/'] : sync.syncedPaths,
|
||||||
|
intervalMinutes: sync.backgroundSyncIntervalMinutes,
|
||||||
|
)
|
||||||
|
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
||||||
|
final password = await store.readPassword(account.id);
|
||||||
|
if (password == null ||
|
||||||
|
config.paths.isEmpty ||
|
||||||
|
config.intervalMinutes == null) {
|
||||||
|
await cancelAccount(account.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await _channel.invokeMethod('reschedule', {
|
||||||
|
'accountId': account.id,
|
||||||
|
'serverUrl': account.serverUrl,
|
||||||
|
'username': account.username,
|
||||||
|
'authHeader': _basicAuth(account, password),
|
||||||
|
'folders': jsonEncode(config.paths),
|
||||||
|
'wifiOnly': wifiOnly,
|
||||||
|
'intervalMinutes': config.intervalMinutes,
|
||||||
|
'notify': sync.syncNotifications,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncService] Could not schedule ${account.id}: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
|
||||||
await _channel.invokeMethod('reschedule', {
|
|
||||||
'accountId': accountId,
|
|
||||||
'serverUrl': session.serverUrl,
|
|
||||||
'username': session.username,
|
|
||||||
'authHeader': authHeader,
|
|
||||||
'folders': jsonEncode(paths),
|
|
||||||
'wifiOnly': !sync.syncOnCellular,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> cancel() async {
|
static String _basicAuth(SavedAccount account, String password) =>
|
||||||
await _channel.invokeMethod('cancel');
|
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
|
||||||
|
|
||||||
|
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||||
|
/// nothing left to sync).
|
||||||
|
static Future<void> cancelAccount(String accountId) async {
|
||||||
|
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a one-off sync pass immediately (Settings' "Sync now"),
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
/// independent of the periodic schedule.
|
/// schedule.
|
||||||
|
///
|
||||||
|
/// [force] (Settings' "Sync now", a newly-added path): full walk of every
|
||||||
|
/// synced root, visible progress notification, supersedes any run already
|
||||||
|
/// in flight. Without it (pull-to-refresh, foreground timer, app resume)
|
||||||
|
/// the pass is a quiet check that uses the cheap root-etag shortcut and
|
||||||
|
/// never interrupts a run already in progress. [respectNetworkSetting]
|
||||||
|
/// makes such a pass wait for Wi-Fi when "Sync on cellular" is off (the
|
||||||
|
/// automatic triggers do; a deliberate pull doesn't).
|
||||||
static Future<void> syncNow(
|
static Future<void> syncNow(
|
||||||
SessionController session,
|
SessionController session,
|
||||||
SyncStatusController sync,
|
SyncStatusController sync, {
|
||||||
) async {
|
bool force = true,
|
||||||
|
bool respectNetworkSetting = false,
|
||||||
|
}) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
final authHeader = session.service?.authHeaders['Authorization'];
|
||||||
if (accountId == null || authHeader == null) {
|
if (accountId == null || authHeader == null) {
|
||||||
@@ -105,26 +164,43 @@ class SyncService {
|
|||||||
'username': session.username,
|
'username': session.username,
|
||||||
'authHeader': authHeader,
|
'authHeader': authHeader,
|
||||||
'folders': jsonEncode(paths),
|
'folders': jsonEncode(paths),
|
||||||
|
'force': force,
|
||||||
|
'notify': sync.syncNotifications,
|
||||||
|
if (respectNetworkSetting) 'wifiOnly': !sync.syncOnCellular,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot status snapshot - used to seed [SyncStatusController]'s state
|
/// One-shot status snapshot - used to seed [SyncStatusController]'s state
|
||||||
/// right after login/account-switch, before the first [statusStream]
|
/// right after login/account-switch, before the first [statusStream]
|
||||||
/// event arrives.
|
/// event arrives.
|
||||||
static Future<SyncStatusSnapshot> getStatus() async {
|
///
|
||||||
|
/// [accountId] is required for `syncedFileIds` to be populated on a fresh
|
||||||
|
/// app start - the native bus only knows an account once a sync pass has
|
||||||
|
/// run in this process.
|
||||||
|
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
||||||
'getSyncStatus',
|
'getSyncStatus',
|
||||||
|
{'accountId': accountId},
|
||||||
);
|
);
|
||||||
return SyncStatusSnapshot.fromMap(result ?? const {});
|
return SyncStatusSnapshot.fromMap(result ?? const {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing
|
/// Live status updates pushed from `SyncStatusBus` (Kotlin) - syncing
|
||||||
/// started/stopped, per-file progress, new/resolved conflicts.
|
/// started/stopped, per-file progress, new/resolved conflicts.
|
||||||
static Stream<SyncStatusSnapshot> get statusStream {
|
///
|
||||||
return _statusChannel.receiveBroadcastStream().map(
|
/// One shared stream, deliberately: every call to
|
||||||
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
/// `EventChannel.receiveBroadcastStream()` opens its *own* native
|
||||||
);
|
/// subscription, and the native side only keeps one listener - a second
|
||||||
}
|
/// subscriber (the Offline tab's controller, once it became eagerly
|
||||||
|
/// created) silently took the events away from the first
|
||||||
|
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
||||||
|
/// was restarted. This is a broadcast stream, so any number of Dart
|
||||||
|
/// listeners share the one native subscription.
|
||||||
|
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.map(
|
||||||
|
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
/// In-app conflict resolution (the header's "Keep local"/"Use server"
|
||||||
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
/// buttons) - shares the exact same native `ConflictResolveWorker`
|
||||||
@@ -184,3 +260,66 @@ class SyncService {
|
|||||||
return file.existsSync() ? file.path : null;
|
return file.existsSync() ? file.path : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One account's background-sync settings, as far as scheduling is
|
||||||
|
/// concerned.
|
||||||
|
class _SyncConfig {
|
||||||
|
final List<String> paths;
|
||||||
|
|
||||||
|
/// Null = background sync off for this account.
|
||||||
|
final int? intervalMinutes;
|
||||||
|
|
||||||
|
const _SyncConfig({required this.paths, required this.intervalMinutes});
|
||||||
|
|
||||||
|
/// Reads a *non-active* account's persisted settings - the same per-account
|
||||||
|
/// keys `SyncStatusController`/`FilesController` write. Malformed values
|
||||||
|
/// fall back to "nothing to sync" rather than throwing.
|
||||||
|
factory _SyncConfig.fromPrefs(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String Function(String accountId, String baseKey) key,
|
||||||
|
String accountId,
|
||||||
|
) {
|
||||||
|
var paths = <String>[];
|
||||||
|
try {
|
||||||
|
final everything =
|
||||||
|
prefs.getBool(
|
||||||
|
key(accountId, SyncStatusController.prefSyncEverything),
|
||||||
|
) ??
|
||||||
|
false;
|
||||||
|
final json = prefs.getString(
|
||||||
|
key(accountId, SyncStatusController.prefSyncedPaths),
|
||||||
|
);
|
||||||
|
if (everything) {
|
||||||
|
paths = ['/'];
|
||||||
|
} else if (json != null) {
|
||||||
|
paths = (jsonDecode(json) as List).cast<String>();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncService] Unreadable sync paths for $accountId: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
var policy = defaultCachePolicy;
|
||||||
|
var minutes = defaultCacheIntervalMinutes;
|
||||||
|
try {
|
||||||
|
final policyName = prefs.getString(
|
||||||
|
key(accountId, FilesController.prefCachePolicy),
|
||||||
|
);
|
||||||
|
policy = CachePolicy.values.firstWhere(
|
||||||
|
(c) => c.name == policyName,
|
||||||
|
orElse: () => defaultCachePolicy,
|
||||||
|
);
|
||||||
|
minutes =
|
||||||
|
prefs.getInt(
|
||||||
|
key(accountId, FilesController.prefCacheIntervalMinutes),
|
||||||
|
) ??
|
||||||
|
defaultCacheIntervalMinutes;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[SyncService] Unreadable cache rule for $accountId: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _SyncConfig(
|
||||||
|
paths: paths,
|
||||||
|
intervalMinutes: policy == CachePolicy.interval ? minutes : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ import '../providers/session_controller.dart';
|
|||||||
import 'native_channel.dart';
|
import 'native_channel.dart';
|
||||||
import 'share_intent_service.dart';
|
import 'share_intent_service.dart';
|
||||||
|
|
||||||
|
/// One upload batch finishing - see [UploadService.completions].
|
||||||
|
class UploadCompletion {
|
||||||
|
final String remoteFolder;
|
||||||
|
final int succeeded;
|
||||||
|
final int failed;
|
||||||
|
|
||||||
|
const UploadCompletion({
|
||||||
|
required this.remoteFolder,
|
||||||
|
required this.succeeded,
|
||||||
|
required this.failed,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UploadCompletion.fromMap(Map<dynamic, dynamic> map) {
|
||||||
|
return UploadCompletion(
|
||||||
|
remoteFolder: map['remoteFolder'] as String? ?? '/',
|
||||||
|
succeeded: (map['succeeded'] as num?)?.toInt() ?? 0,
|
||||||
|
failed: (map['failed'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android
|
/// Hands a "Share to Noo" upload off to `ShareUploadService.kt`'s Android
|
||||||
/// foreground service, which does the actual prepare+upload entirely on its
|
/// foreground service, which does the actual prepare+upload entirely on its
|
||||||
/// own from there - including surviving the app being closed - with a
|
/// own from there - including surviving the app being closed - with a
|
||||||
@@ -14,11 +35,15 @@ import 'share_intent_service.dart';
|
|||||||
/// real Android Service does.
|
/// real Android Service does.
|
||||||
class UploadService {
|
class UploadService {
|
||||||
static const _channel = MethodChannel('dev.ayushya.noo/upload_service');
|
static const _channel = MethodChannel('dev.ayushya.noo/upload_service');
|
||||||
|
static const _statusChannel = EventChannel(
|
||||||
|
'dev.ayushya.noo/upload_service/status',
|
||||||
|
);
|
||||||
|
|
||||||
/// Starts uploading [files] into the active account's current folder.
|
/// Starts uploading [files] into the active account's current folder.
|
||||||
/// Fire-and-forget from Dart's perspective - once this returns, the
|
/// Fire-and-forget from Dart's perspective - once this returns, the
|
||||||
/// service owns the rest and reports progress/completion/cancellation
|
/// service owns the rest and reports progress/completion/cancellation
|
||||||
/// through its own notification, not back to the app.
|
/// through its own notification, not back to the app. [completions] is
|
||||||
|
/// the one thing it does report back, for `FilesController` to act on.
|
||||||
static Future<void> startUpload(
|
static Future<void> startUpload(
|
||||||
SessionController session,
|
SessionController session,
|
||||||
FilesController files,
|
FilesController files,
|
||||||
@@ -37,4 +62,15 @@ class UploadService {
|
|||||||
'remoteFolder': files.currentFolderPath,
|
'remoteFolder': files.currentFolderPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fires once per finished batch (at least one file uploaded) from
|
||||||
|
/// `UploadEventBus` (Kotlin) - lets `FilesController` refresh the
|
||||||
|
/// destination folder if it's the one currently on screen, instead of the
|
||||||
|
/// list only updating on a manual pull-to-refresh. One shared stream,
|
||||||
|
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
||||||
|
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
||||||
|
/// subscription per call, and the native side only keeps the latest one.
|
||||||
|
static final Stream<UploadCompletion> completions = _statusChannel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'design_tokens.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static const Color defaultNextcloudBlue = Color(0xFF0082C9);
|
static const Color defaultNextcloudBlue = Color(0xFF0082C9);
|
||||||
|
|
||||||
|
/// The design system's own fixed violet - [NooColors.light]/`.dark`'s
|
||||||
|
/// `accent`, and the accent picker's "Default" swatch. Also
|
||||||
|
/// [SettingsController]'s initial [SettingsController.seedColor], so a
|
||||||
|
/// fresh install (or any device without Material You dynamic color) keeps
|
||||||
|
/// today's violet look rather than picking up the leftover
|
||||||
|
/// [defaultNextcloudBlue] default from before seed colors actually did
|
||||||
|
/// anything to the Noo-styled UI (see [NooColors.fromSeed]).
|
||||||
|
static const Color defaultAccent = Color(0xFF8D0DE3);
|
||||||
|
|
||||||
// Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't
|
// Flutter's Material 3 default (ZoomPageTransitionsBuilder) doesn't
|
||||||
// implement Android's predictive-back gesture at all. Opting into
|
// implement Android's predictive-back gesture at all. Opting into
|
||||||
// PredictiveBackPageTransitionsBuilder here (Android only) is required —
|
// PredictiveBackPageTransitionsBuilder here (Android only) is required —
|
||||||
@@ -23,6 +33,7 @@ class AppTheme {
|
|||||||
static const _sliderTheme = SliderThemeData(year2023: false);
|
static const _sliderTheme = SliderThemeData(year2023: false);
|
||||||
|
|
||||||
static const List<Color> seedColors = [
|
static const List<Color> seedColors = [
|
||||||
|
defaultAccent, // Default (Noo Violet)
|
||||||
Color(0xFF0082C9), // Nextcloud Blue
|
Color(0xFF0082C9), // Nextcloud Blue
|
||||||
Color(0xFF009688), // Ocean Teal
|
Color(0xFF009688), // Ocean Teal
|
||||||
Color(0xFF6750A4), // Deep Purple
|
Color(0xFF6750A4), // Deep Purple
|
||||||
@@ -30,6 +41,49 @@ class AppTheme {
|
|||||||
Color(0xFF2E7D32), // Emerald Green
|
Color(0xFF2E7D32), // Emerald Green
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Design-system typefaces (DESIGN_SYSTEM.md 1.3): Schibsted Grotesk for
|
||||||
|
// headings, Instrument Sans for UI/body text. Applied over Flutter's
|
||||||
|
// default TextTheme slots app-wide - screens not yet reworked to the
|
||||||
|
// design system's own named type scale (`NooText`) still benefit from
|
||||||
|
// the new typefaces immediately instead of staying on Inter until their
|
||||||
|
// turn comes.
|
||||||
|
static TextTheme _textTheme(Brightness brightness) {
|
||||||
|
final base = brightness == Brightness.light
|
||||||
|
? ThemeData.light().textTheme
|
||||||
|
: ThemeData.dark().textTheme;
|
||||||
|
return GoogleFonts.instrumentSansTextTheme(base).copyWith(
|
||||||
|
displayLarge: GoogleFonts.schibstedGrotesk(
|
||||||
|
textStyle: base.displayLarge,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
displayMedium: GoogleFonts.schibstedGrotesk(
|
||||||
|
textStyle: base.displayMedium,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
displaySmall: GoogleFonts.schibstedGrotesk(
|
||||||
|
textStyle: base.displaySmall,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
headlineLarge: GoogleFonts.schibstedGrotesk(
|
||||||
|
textStyle: base.headlineLarge,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
headlineMedium: GoogleFonts.schibstedGrotesk(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
headlineSmall: GoogleFonts.schibstedGrotesk(
|
||||||
|
textStyle: base.headlineSmall,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
titleLarge: GoogleFonts.schibstedGrotesk(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
titleMedium: GoogleFonts.schibstedGrotesk(fontWeight: FontWeight.w500),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static ThemeData light(
|
static ThemeData light(
|
||||||
Color seedColor, {
|
Color seedColor, {
|
||||||
ColorScheme? dynamicScheme,
|
ColorScheme? dynamicScheme,
|
||||||
@@ -41,26 +95,19 @@ class AppTheme {
|
|||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
);
|
);
|
||||||
|
final nooColors = (useDynamicColor && dynamicScheme != null)
|
||||||
|
? NooColors.fromDynamicScheme(dynamicScheme)
|
||||||
|
: NooColors.fromSeed(seedColor, Brightness.light);
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
extensions: [nooColors],
|
||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
pageTransitionsTheme: _pageTransitionsTheme,
|
pageTransitionsTheme: _pageTransitionsTheme,
|
||||||
sliderTheme: _sliderTheme,
|
sliderTheme: _sliderTheme,
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme)
|
textTheme: _textTheme(Brightness.light),
|
||||||
.copyWith(
|
|
||||||
headlineMedium: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
@@ -71,7 +118,7 @@ class AppTheme {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: GoogleFonts.schibstedGrotesk(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
@@ -92,6 +139,9 @@ class AppTheme {
|
|||||||
seedColor: seedColor,
|
seedColor: seedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
);
|
);
|
||||||
|
var nooColors = (useDynamicColor && dynamicScheme != null)
|
||||||
|
? NooColors.fromDynamicScheme(dynamicScheme)
|
||||||
|
: NooColors.fromSeed(seedColor, Brightness.dark);
|
||||||
|
|
||||||
if (amoled) {
|
if (amoled) {
|
||||||
// True OLED black across every surface tone Material 3 hands out -
|
// True OLED black across every surface tone Material 3 hands out -
|
||||||
@@ -106,27 +156,18 @@ class AppTheme {
|
|||||||
surfaceContainerHighest: Colors.black,
|
surfaceContainerHighest: Colors.black,
|
||||||
surfaceDim: Colors.black,
|
surfaceDim: Colors.black,
|
||||||
);
|
);
|
||||||
|
nooColors = nooColors.withAmoled();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
|
extensions: [nooColors],
|
||||||
scaffoldBackgroundColor: colorScheme.surface,
|
scaffoldBackgroundColor: colorScheme.surface,
|
||||||
pageTransitionsTheme: _pageTransitionsTheme,
|
pageTransitionsTheme: _pageTransitionsTheme,
|
||||||
sliderTheme: _sliderTheme,
|
sliderTheme: _sliderTheme,
|
||||||
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme)
|
textTheme: _textTheme(Brightness.dark),
|
||||||
.copyWith(
|
|
||||||
headlineMedium: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
titleLarge: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
titleMedium: GoogleFonts.inter(fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
@@ -137,7 +178,7 @@ class AppTheme {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: GoogleFonts.inter(
|
titleTextStyle: GoogleFonts.schibstedGrotesk(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
/// Semantic color tokens from `.claude/context/design-system/DESIGN_SYSTEM.md`
|
||||||
|
/// section 1.1 - read that file before changing any value here. Registered
|
||||||
|
/// on [ThemeData.extensions], so call sites reach these via
|
||||||
|
/// `Theme.of(context).extension<NooColors>()!` (or a `context.nooColors`
|
||||||
|
/// getter, once one exists) instead of the Material [ColorScheme] roles.
|
||||||
|
///
|
||||||
|
/// Three sources feed this, matching the app's color settings: the fixed
|
||||||
|
/// [light]/[dark] palettes below (the design system's own default look,
|
||||||
|
/// also what the accent picker's "Default" violet swatch selects),
|
||||||
|
/// [NooColors.fromDynamicScheme] when the user has Material You dynamic
|
||||||
|
/// color turned on, or [NooColors.fromSeed] for a custom accent-picker
|
||||||
|
/// swatch. [withAmoled] layers on top of any of the three when the user's
|
||||||
|
/// AMOLED toggle is on.
|
||||||
|
@immutable
|
||||||
|
class NooColors extends ThemeExtension<NooColors> {
|
||||||
|
final Color bg;
|
||||||
|
final Color surface;
|
||||||
|
final Color surface2;
|
||||||
|
final Color surface3;
|
||||||
|
final Color line;
|
||||||
|
final Color fg1;
|
||||||
|
final Color fg2;
|
||||||
|
final Color fg3;
|
||||||
|
final Color accent;
|
||||||
|
final Color accentText;
|
||||||
|
final Color accentSoft;
|
||||||
|
final Color danger;
|
||||||
|
final Color dangerSoft;
|
||||||
|
final Color dangerFill;
|
||||||
|
final Color success;
|
||||||
|
final Color successSoft;
|
||||||
|
final Color warning;
|
||||||
|
final Color warningSoft;
|
||||||
|
final Color info;
|
||||||
|
final Color infoSoft;
|
||||||
|
final Color scrim;
|
||||||
|
|
||||||
|
const NooColors({
|
||||||
|
required this.bg,
|
||||||
|
required this.surface,
|
||||||
|
required this.surface2,
|
||||||
|
required this.surface3,
|
||||||
|
required this.line,
|
||||||
|
required this.fg1,
|
||||||
|
required this.fg2,
|
||||||
|
required this.fg3,
|
||||||
|
required this.accent,
|
||||||
|
required this.accentText,
|
||||||
|
required this.accentSoft,
|
||||||
|
required this.danger,
|
||||||
|
required this.dangerSoft,
|
||||||
|
required this.dangerFill,
|
||||||
|
required this.success,
|
||||||
|
required this.successSoft,
|
||||||
|
required this.warning,
|
||||||
|
required this.warningSoft,
|
||||||
|
required this.info,
|
||||||
|
required this.infoSoft,
|
||||||
|
required this.scrim,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const light = NooColors(
|
||||||
|
bg: Color(0xFFF3EFE6),
|
||||||
|
surface: Color(0xFFFFFFFF),
|
||||||
|
surface2: Color(0xFFF6F5F2),
|
||||||
|
surface3: Color(0xFFEAE8E2),
|
||||||
|
line: Color(0xFFEAE8E2),
|
||||||
|
fg1: Color(0xFF0E0D0B),
|
||||||
|
fg2: Color(0xFF5A5852),
|
||||||
|
fg3: Color(0xFF7C7A72),
|
||||||
|
accent: Color(0xFF8D0DE3),
|
||||||
|
accentText: Color(0xFF8D0DE3),
|
||||||
|
accentSoft: Color(0xFFECDFFF),
|
||||||
|
danger: Color(0xFFA8202A),
|
||||||
|
dangerSoft: Color(0xFFFDE7E7),
|
||||||
|
dangerFill: Color(0xFFA8202A),
|
||||||
|
success: Color(0xFF3E7A23),
|
||||||
|
successSoft: Color(0xFFEAF5DF),
|
||||||
|
warning: Color(0xFF8C5A0E),
|
||||||
|
warningSoft: Color(0xFFFBEFD0),
|
||||||
|
info: Color(0xFF2940A8),
|
||||||
|
infoSoft: Color(0xFFE6ECFE),
|
||||||
|
scrim: Color(0x730E0D0B),
|
||||||
|
);
|
||||||
|
|
||||||
|
static const dark = NooColors(
|
||||||
|
bg: Color(0xFF0E0D0B),
|
||||||
|
surface: Color(0xFF25241F),
|
||||||
|
surface2: Color(0xFF3D3C38),
|
||||||
|
surface3: Color(0xFF5A5852),
|
||||||
|
line: Color(0xFF3D3C38),
|
||||||
|
fg1: Color(0xFFF3EFE6),
|
||||||
|
fg2: Color(0xFFBDBAB1),
|
||||||
|
fg3: Color(0xFF9E9B92),
|
||||||
|
accent: Color(0xFF8D0DE3),
|
||||||
|
accentText: Color(0xFFCBA5FD),
|
||||||
|
accentSoft: Color(0xFF2F0454),
|
||||||
|
danger: Color(0xFFF49E9E),
|
||||||
|
dangerSoft: Color(0xFF560F18),
|
||||||
|
dangerFill: Color(0xFFA8202A),
|
||||||
|
success: Color(0xFFB7DD9F),
|
||||||
|
successSoft: Color(0xFF1E3F10),
|
||||||
|
warning: Color(0xFFF0C26C),
|
||||||
|
warningSoft: Color(0xFF4A3208),
|
||||||
|
info: Color(0xFFB6C3FB),
|
||||||
|
infoSoft: Color(0xFF1C2B73),
|
||||||
|
scrim: Color(0x99000000),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Avatar/photo-placeholder fills (`DESIGN_SYSTEM.md` 1.1) - a fixed
|
||||||
|
/// secondary palette, same in light and dark, always paired with
|
||||||
|
/// [avatarTextColor]. Content-identity colors, not theme, so they don't
|
||||||
|
/// change with brightness or dynamic color (same reasoning as file-type
|
||||||
|
/// tinting in `styling.md`).
|
||||||
|
static const avatarPalette = [
|
||||||
|
Color(0xFF8DCDE2),
|
||||||
|
Color(0xFF96B0FD),
|
||||||
|
Color(0xFFE5D8BD),
|
||||||
|
Color(0xFFEEEDB3),
|
||||||
|
Color(0xFFDA9AC7),
|
||||||
|
Color(0xFFA7D296),
|
||||||
|
Color(0xFFE9A6A7),
|
||||||
|
Color(0xFF85D1BD),
|
||||||
|
Color(0xFFCBA5FD),
|
||||||
|
];
|
||||||
|
static const avatarTextColor = Color(0xFF0E0D0B);
|
||||||
|
|
||||||
|
/// Maps a Material You dynamic [ColorScheme] onto these roles for users
|
||||||
|
/// who've turned dynamic color on, instead of the fixed [light]/[dark]
|
||||||
|
/// palettes. `success`/`successSoft`/`warning`/`warningSoft`/`info`/
|
||||||
|
/// `infoSoft` have no Material dynamic equivalent and are content-identity
|
||||||
|
/// colors regardless (file-type tiles) rather than brand colors, so they
|
||||||
|
/// always come from the fixed palette matching [cs]'s brightness. Same
|
||||||
|
/// for `scrim`: Material 3's `ColorScheme.scrim` role is fully *opaque*
|
||||||
|
/// black (`0xFF000000`), meant to be used at reduced opacity by whoever
|
||||||
|
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||||
|
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||||
|
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||||
|
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||||
|
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
||||||
|
return NooColors(
|
||||||
|
bg: cs.surface,
|
||||||
|
surface: cs.surfaceContainerLow,
|
||||||
|
surface2: cs.surfaceContainerHigh,
|
||||||
|
surface3: cs.surfaceContainerHighest,
|
||||||
|
line: cs.outlineVariant,
|
||||||
|
fg1: cs.onSurface,
|
||||||
|
fg2: cs.onSurfaceVariant,
|
||||||
|
fg3: cs.onSurfaceVariant,
|
||||||
|
accent: cs.primary,
|
||||||
|
accentText: cs.primary,
|
||||||
|
accentSoft: cs.primaryContainer,
|
||||||
|
danger: cs.error,
|
||||||
|
dangerSoft: cs.errorContainer,
|
||||||
|
dangerFill: cs.error,
|
||||||
|
success: fixed.success,
|
||||||
|
successSoft: fixed.successSoft,
|
||||||
|
warning: fixed.warning,
|
||||||
|
warningSoft: fixed.warningSoft,
|
||||||
|
info: fixed.info,
|
||||||
|
infoSoft: fixed.infoSoft,
|
||||||
|
scrim: fixed.scrim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derives an accent-only variant of the fixed [light]/[dark] palette for
|
||||||
|
/// a user-chosen custom seed color (Settings' accent-color picker) -
|
||||||
|
/// unlike [fromDynamicScheme], only the accent roles change; bg/surface/
|
||||||
|
/// fg etc. stay the Noo warm-neutral palette, since picking a named
|
||||||
|
/// accent ("Ocean Teal") is meant to retint the brand color, not reshape
|
||||||
|
/// the whole surface to match a wallpaper the way dynamic color does.
|
||||||
|
/// [seedColor] equal to the fixed palette's own accent (the picker's
|
||||||
|
/// "Default" violet swatch) returns the untouched fixed palette, so the
|
||||||
|
/// out-of-box look stays pixel-identical rather than going through
|
||||||
|
/// [ColorScheme.fromSeed]'s tonal-palette math for no reason.
|
||||||
|
factory NooColors.fromSeed(Color seedColor, Brightness brightness) {
|
||||||
|
final fixed = brightness == Brightness.dark ? dark : light;
|
||||||
|
if (seedColor == light.accent) return fixed;
|
||||||
|
final cs = ColorScheme.fromSeed(
|
||||||
|
seedColor: seedColor,
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
return fixed.copyWith(
|
||||||
|
accent: seedColor,
|
||||||
|
// Light accentText matches accent exactly, same as the fixed
|
||||||
|
// palette - dark needs a lighter, desaturated tint for contrast
|
||||||
|
// against a dark surface, which is exactly what Material's tonal
|
||||||
|
// primary (tone ~80 in dark mode) is designed to give.
|
||||||
|
accentText: brightness == Brightness.dark ? cs.primary : seedColor,
|
||||||
|
accentSoft: cs.primaryContainer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True OLED black across every surface tone - not just [bg] - so cards,
|
||||||
|
/// bars and sheets all go black too instead of the dark palette's warm
|
||||||
|
/// dark-grey tones. Mirrors the AMOLED handling this replaces in
|
||||||
|
/// `AppTheme.dark` (see git history), just against these tokens instead
|
||||||
|
/// of Material [ColorScheme] roles.
|
||||||
|
NooColors withAmoled() => copyWith(
|
||||||
|
bg: Colors.black,
|
||||||
|
surface: Colors.black,
|
||||||
|
surface2: Colors.black,
|
||||||
|
surface3: Colors.black,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
NooColors copyWith({
|
||||||
|
Color? bg,
|
||||||
|
Color? surface,
|
||||||
|
Color? surface2,
|
||||||
|
Color? surface3,
|
||||||
|
Color? line,
|
||||||
|
Color? fg1,
|
||||||
|
Color? fg2,
|
||||||
|
Color? fg3,
|
||||||
|
Color? accent,
|
||||||
|
Color? accentText,
|
||||||
|
Color? accentSoft,
|
||||||
|
Color? danger,
|
||||||
|
Color? dangerSoft,
|
||||||
|
Color? dangerFill,
|
||||||
|
Color? success,
|
||||||
|
Color? successSoft,
|
||||||
|
Color? warning,
|
||||||
|
Color? warningSoft,
|
||||||
|
Color? info,
|
||||||
|
Color? infoSoft,
|
||||||
|
Color? scrim,
|
||||||
|
}) {
|
||||||
|
return NooColors(
|
||||||
|
bg: bg ?? this.bg,
|
||||||
|
surface: surface ?? this.surface,
|
||||||
|
surface2: surface2 ?? this.surface2,
|
||||||
|
surface3: surface3 ?? this.surface3,
|
||||||
|
line: line ?? this.line,
|
||||||
|
fg1: fg1 ?? this.fg1,
|
||||||
|
fg2: fg2 ?? this.fg2,
|
||||||
|
fg3: fg3 ?? this.fg3,
|
||||||
|
accent: accent ?? this.accent,
|
||||||
|
accentText: accentText ?? this.accentText,
|
||||||
|
accentSoft: accentSoft ?? this.accentSoft,
|
||||||
|
danger: danger ?? this.danger,
|
||||||
|
dangerSoft: dangerSoft ?? this.dangerSoft,
|
||||||
|
dangerFill: dangerFill ?? this.dangerFill,
|
||||||
|
success: success ?? this.success,
|
||||||
|
successSoft: successSoft ?? this.successSoft,
|
||||||
|
warning: warning ?? this.warning,
|
||||||
|
warningSoft: warningSoft ?? this.warningSoft,
|
||||||
|
info: info ?? this.info,
|
||||||
|
infoSoft: infoSoft ?? this.infoSoft,
|
||||||
|
scrim: scrim ?? this.scrim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
NooColors lerp(ThemeExtension<NooColors>? other, double t) {
|
||||||
|
if (other is! NooColors) return this;
|
||||||
|
return NooColors(
|
||||||
|
bg: Color.lerp(bg, other.bg, t)!,
|
||||||
|
surface: Color.lerp(surface, other.surface, t)!,
|
||||||
|
surface2: Color.lerp(surface2, other.surface2, t)!,
|
||||||
|
surface3: Color.lerp(surface3, other.surface3, t)!,
|
||||||
|
line: Color.lerp(line, other.line, t)!,
|
||||||
|
fg1: Color.lerp(fg1, other.fg1, t)!,
|
||||||
|
fg2: Color.lerp(fg2, other.fg2, t)!,
|
||||||
|
fg3: Color.lerp(fg3, other.fg3, t)!,
|
||||||
|
accent: Color.lerp(accent, other.accent, t)!,
|
||||||
|
accentText: Color.lerp(accentText, other.accentText, t)!,
|
||||||
|
accentSoft: Color.lerp(accentSoft, other.accentSoft, t)!,
|
||||||
|
danger: Color.lerp(danger, other.danger, t)!,
|
||||||
|
dangerSoft: Color.lerp(dangerSoft, other.dangerSoft, t)!,
|
||||||
|
dangerFill: Color.lerp(dangerFill, other.dangerFill, t)!,
|
||||||
|
success: Color.lerp(success, other.success, t)!,
|
||||||
|
successSoft: Color.lerp(successSoft, other.successSoft, t)!,
|
||||||
|
warning: Color.lerp(warning, other.warning, t)!,
|
||||||
|
warningSoft: Color.lerp(warningSoft, other.warningSoft, t)!,
|
||||||
|
info: Color.lerp(info, other.info, t)!,
|
||||||
|
infoSoft: Color.lerp(infoSoft, other.infoSoft, t)!,
|
||||||
|
scrim: Color.lerp(scrim, other.scrim, t)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the app's [NooColors] - registered on every [ThemeData] built by
|
||||||
|
/// `AppTheme`, so this is never actually null in a running app.
|
||||||
|
extension NooColorsContext on BuildContext {
|
||||||
|
NooColors get nooColors => Theme.of(this).extension<NooColors>()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 4px spacing scale from the Noo Design System project's
|
||||||
|
/// `tokens/spacing.css` (claude.ai/design, project id
|
||||||
|
/// bb69c00d-f400-445c-9683-355668c0bfe1 - the authoritative source now;
|
||||||
|
/// `.claude/context/design-system/` is the earlier, less precise mockup
|
||||||
|
/// export).
|
||||||
|
class NooSpace {
|
||||||
|
const NooSpace._();
|
||||||
|
static const double xxs = 4;
|
||||||
|
static const double xs = 8;
|
||||||
|
static const double sm = 12;
|
||||||
|
static const double smd = 14;
|
||||||
|
static const double md = 16;
|
||||||
|
static const double lg = 20;
|
||||||
|
static const double xl = 24;
|
||||||
|
static const double xxl = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corner radii from `tokens/spacing.css`. `pill` covers every button,
|
||||||
|
/// chip, segmented control, toggle, search field and badge.
|
||||||
|
class NooRadii {
|
||||||
|
const NooRadii._();
|
||||||
|
static const double pill = 999;
|
||||||
|
static const double card = 20;
|
||||||
|
static const double gridCard = 18;
|
||||||
|
static const double input = 14;
|
||||||
|
static const double sidebarItem = 12;
|
||||||
|
static const double sheetTop = 28;
|
||||||
|
static const double dialog = 24;
|
||||||
|
static const double dropdown = 10;
|
||||||
|
static const double screenCornerAndroid = 36;
|
||||||
|
static const double screenCornerIOS = 48;
|
||||||
|
static const double windowMac = 12;
|
||||||
|
static const double windowWin = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed heights/widths from `tokens/spacing.css` - button sizes, row
|
||||||
|
/// heights, the desktop sidebar width. Named to match the component that
|
||||||
|
/// uses each one (`NooButton`'s `size` picks from the `button*` set).
|
||||||
|
class NooSizes {
|
||||||
|
const NooSizes._();
|
||||||
|
static const double buttonCta = 52;
|
||||||
|
static const double buttonCard = 44;
|
||||||
|
static const double buttonField = 40;
|
||||||
|
static const double buttonToolbar = 36;
|
||||||
|
static const double buttonCompact = 32;
|
||||||
|
static const double buttonXs = 28;
|
||||||
|
static const double rowMobile = 64;
|
||||||
|
static const double rowDesktop = 52;
|
||||||
|
static const double settingsRow = 52;
|
||||||
|
static const double sidebarItem = 38;
|
||||||
|
static const double toolbar = 64;
|
||||||
|
static const double sidebarWidth = 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Motion constants from `tokens/spacing.css`'s `--ease`/`--dur-*`/
|
||||||
|
/// `--press-scale`.
|
||||||
|
class NooMotion {
|
||||||
|
const NooMotion._();
|
||||||
|
static const Curve ease = Cubic(0.7, 0, 0.15, 1);
|
||||||
|
static const Duration fast = Duration(milliseconds: 180);
|
||||||
|
static const Duration base = Duration(milliseconds: 280);
|
||||||
|
static const Duration slow = Duration(milliseconds: 480);
|
||||||
|
static const double pressScale = 0.98;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
||||||
|
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
||||||
|
/// bar/search satellite (the only other thing in the app that's genuinely
|
||||||
|
/// floating above other content). Nothing else should use a `BoxShadow`.
|
||||||
|
const nooDialogShadow = BoxShadow(
|
||||||
|
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
||||||
|
blurRadius: 48,
|
||||||
|
offset: Offset(0, 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The named type scale from `DESIGN_SYSTEM.md` 1.3 - mobile sizes only for
|
||||||
|
/// now (desktop rework hasn't started). Schibsted Grotesk for headings,
|
||||||
|
/// Instrument Sans for UI text, per the spec. Color is deliberately not set
|
||||||
|
/// here - callers pull it from [NooColors] since the same style (e.g.
|
||||||
|
/// `meta`) is reused across surfaces with different foreground colors.
|
||||||
|
class NooText {
|
||||||
|
const NooText._();
|
||||||
|
|
||||||
|
static TextStyle get largeTitle => GoogleFonts.schibstedGrotesk(
|
||||||
|
fontSize: 34,
|
||||||
|
height: 0.9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -34 * 0.03,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get stat => GoogleFonts.schibstedGrotesk(
|
||||||
|
fontSize: 32,
|
||||||
|
height: 0.9,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -32 * 0.03,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get title => GoogleFonts.schibstedGrotesk(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: -22 * 0.02,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get cardTitle => GoogleFonts.schibstedGrotesk(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: -20 * 0.02,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Desktop `GroupedList` title / share dialog headings - 17px, between
|
||||||
|
/// [cardTitle] and [groupHeading].
|
||||||
|
static TextStyle get sectionTitle => GoogleFonts.schibstedGrotesk(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: -17 * 0.02,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get groupHeading => GoogleFonts.schibstedGrotesk(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
letterSpacing: -18 * 0.02,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get bodyL => GoogleFonts.instrumentSans(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.2,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get body => GoogleFonts.instrumentSans(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.3,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get label =>
|
||||||
|
GoogleFonts.instrumentSans(fontSize: 13, fontWeight: FontWeight.w600);
|
||||||
|
|
||||||
|
static TextStyle get meta => GoogleFonts.instrumentSans(
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.2,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Bottom-nav label, idle state (Android: 12/500 fg-2 - see
|
||||||
|
/// `Mobile Screen.dc.html`'s `aNav` block). Use [navLabelActive] for the
|
||||||
|
/// selected destination.
|
||||||
|
static TextStyle get navLabel =>
|
||||||
|
GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w500);
|
||||||
|
|
||||||
|
/// Bottom-nav label, active state (Android: 12/600 fg-1, not accent-text -
|
||||||
|
/// only the icon's pill uses accent-soft/accent-text).
|
||||||
|
static TextStyle get navLabelActive =>
|
||||||
|
GoogleFonts.instrumentSans(fontSize: 12, fontWeight: FontWeight.w600);
|
||||||
|
|
||||||
|
static TextStyle get button =>
|
||||||
|
GoogleFonts.instrumentSans(fontSize: 15, fontWeight: FontWeight.w600);
|
||||||
|
|
||||||
|
static TextStyle get buttonSm =>
|
||||||
|
GoogleFonts.instrumentSans(fontSize: 14, fontWeight: FontWeight.w600);
|
||||||
|
|
||||||
|
static const mono = TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontFamilyFallback: ['ui-monospace', 'Menlo'],
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,141 +1,138 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/files_controller.dart';
|
import '../providers/files_controller.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../providers/session_controller.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||||
|
import '../widgets/noo/media/noo_activity_item.dart';
|
||||||
|
import '../widgets/noo/media/noo_photo_tile.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/tabs/tab_day_groups.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
|
|
||||||
|
/// The whole-account activity feed - lives on [FilesController] (fetched
|
||||||
|
/// alongside the current Files folder) rather than its own controller, see
|
||||||
|
/// `FilesController.refreshData`. Grouped by calendar day per
|
||||||
|
/// `DESIGN_SYSTEM.md` §4; desktop centers the feed at 760px.
|
||||||
|
///
|
||||||
|
/// [NextcloudActivity] only carries a rendered `title`/`subject` string
|
||||||
|
/// pair plus an `author` username - the server response has no structured
|
||||||
|
/// actor/verb/object breakdown or file reference, so the bold "actor"
|
||||||
|
/// segment is the author and the rest of the sentence (already fully
|
||||||
|
/// formed server-side) rides as one plain run rather than being split
|
||||||
|
/// further, and there's no reliable file to show in the trailing file
|
||||||
|
/// tile slot - see this screen's rebuild report for what a richer feed
|
||||||
|
/// would need from the service layer.
|
||||||
class ActivityView extends StatelessWidget {
|
class ActivityView extends StatelessWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const ActivityView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const ActivityView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
|
final session = context.watch<SessionController>();
|
||||||
final activities = files.activities;
|
final activities = files.activities;
|
||||||
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
final groups = groupByCalendarDay<NextcloudActivity>(
|
||||||
|
activities,
|
||||||
|
(a) => a.timestamp,
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget feed;
|
||||||
|
if (files.isLoading && activities.isEmpty) {
|
||||||
|
feed = tabLoadingSliver;
|
||||||
|
} else if (activities.isEmpty) {
|
||||||
|
feed = tabEmptySliver(
|
||||||
|
context,
|
||||||
|
icon: LucideIcons.activity,
|
||||||
|
message: 'No recent activity',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
feed = SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final group = groups[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 18),
|
||||||
|
child: NooGroupedList(
|
||||||
|
label: group.label,
|
||||||
|
children: [
|
||||||
|
for (final act in group.items)
|
||||||
|
Material(
|
||||||
|
color: colors.surface,
|
||||||
|
child: NooActivityItem(
|
||||||
|
actor: act.author,
|
||||||
|
verb: act.title,
|
||||||
|
tail: act.subject.isNotEmpty && act.subject != act.title
|
||||||
|
? act.subject
|
||||||
|
: null,
|
||||||
|
time: DateFormat.jm().format(act.timestamp),
|
||||||
|
avatarColor: NooPhotoTile.paletteColor(
|
||||||
|
act.author.hashCode,
|
||||||
|
),
|
||||||
|
currentUser:
|
||||||
|
act.author.toLowerCase() ==
|
||||||
|
session.username.toLowerCase(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, childCount: groups.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
if (topBar != null) topBarSliver(topBar!),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||||
if (files.isLoading && activities.isEmpty)
|
feed,
|
||||||
const SliverFillRemaining(
|
...tabBottomInsetSlivers(context),
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
)
|
|
||||||
else if (activities.isEmpty)
|
|
||||||
SliverFillRemaining(
|
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'No recent activity',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final act = activities[index];
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
act.icon,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
act.title,
|
|
||||||
style: theme.textTheme.titleMedium
|
|
||||||
?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
DateFormat.jm().format(act.timestamp),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
act.subject,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
'By ${act.author} • ${DateFormat.MMMd().format(act.timestamp)}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, childCount: activities.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return SyncedHeaderScaffold(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
scrollController: scrollController,
|
// controls row rides up under the status bar once the floating top bar
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
// above it fully collapses.
|
||||||
contentSlivers: contentSlivers,
|
final scrollView = SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item feed.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ColoredBox(
|
||||||
|
color: colors.bg,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
color: colors.accent,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
onRefresh: () => context.read<FilesController>().refreshData(),
|
||||||
|
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
|
||||||
|
// rather than left-aligned, so a wide window doesn't stretch the
|
||||||
|
// feed's short sentences edge to edge.
|
||||||
|
child: isDesktop
|
||||||
|
? Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 760),
|
||||||
|
child: scrollView,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: scrollView,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
@@ -13,16 +13,24 @@ import '../providers/item_operations.dart';
|
|||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
import '../providers/settings_controller.dart';
|
import '../providers/settings_controller.dart';
|
||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
import '../widgets/item_icon.dart';
|
import '../widgets/files_controls_row.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
import '../widgets/segmented_icon_toggle.dart';
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
import '../widgets/selectable_thumbnail.dart';
|
import '../widgets/noo/files/noo_file_tile.dart';
|
||||||
|
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||||
|
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||||
|
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||||
|
import '../widgets/noo/media/noo_grid_card.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sort_menu_button.dart';
|
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
|
|
||||||
@@ -35,97 +43,41 @@ import 'move_copy_destination_picker.dart';
|
|||||||
/// wide list also means the breadcrumb-vs-content mismatch a filter
|
/// wide list also means the breadcrumb-vs-content mismatch a filter
|
||||||
/// toggle had - the trail showing wherever Files was last browsing while
|
/// toggle had - the trail showing wherever Files was last browsing while
|
||||||
/// the content showed something else entirely - simply can't happen).
|
/// the content showed something else entirely - simply can't happen).
|
||||||
/// Shares Files' own sort/hidden/storage-scope/grid-list display prefs
|
/// Shares Files' own sort/hidden/type-filter/storage-scope/grid-list
|
||||||
/// (via `FilesController.applyFilesDisplayPrefs`) rather than a separate
|
/// display prefs (via `FilesController.applyFilesDisplayPrefs`, and
|
||||||
/// parallel settings dimension.
|
/// `FilesControlsRow` for the controls themselves) rather than a separate
|
||||||
|
/// parallel settings dimension. Renders content only - the shell owns the
|
||||||
|
/// top bar, bottom bar, drawer and FAB.
|
||||||
class FavoritesView extends StatefulWidget {
|
class FavoritesView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const FavoritesView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const FavoritesView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FavoritesView> createState() => _FavoritesViewState();
|
State<FavoritesView> createState() => _FavoritesViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FavoritesViewState extends State<FavoritesView>
|
class _FavoritesViewState extends State<FavoritesView> {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
bool _requested = false;
|
bool _requested = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
|
||||||
final List<AnimationController> _scrollHintControllers = [];
|
|
||||||
|
|
||||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
void _toggleSelection(NextcloudItem item) {
|
void _toggleSelection(NextcloudItem item) {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
final enteringSelection = _selectedIds.isEmpty;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
if (enteringSelection && _isSelecting) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
|
||||||
(_) => _playScrollHint(_selectionActionsScrollController),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `FilesView`/`PhotosView`'s identical scroll-hint - see their
|
|
||||||
/// doc comment: nudges the selection actions row right and back, once,
|
|
||||||
/// the first time a selection starts. No-ops if there's nothing to
|
|
||||||
/// scroll (row already fits).
|
|
||||||
Future<void> _playScrollHint(ScrollController scrollController) async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
if (!mounted || !scrollController.hasClients) return;
|
|
||||||
final maxExtent = scrollController.position.maxScrollExtent;
|
|
||||||
if (maxExtent <= 0) return;
|
|
||||||
final double peak = maxExtent < 36 ? maxExtent : 36;
|
|
||||||
final controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 900),
|
|
||||||
);
|
|
||||||
_scrollHintControllers.add(controller);
|
|
||||||
final hint = TweenSequence<double>([
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween(
|
|
||||||
begin: 0.0,
|
|
||||||
end: peak,
|
|
||||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
|
||||||
weight: 50,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween(
|
|
||||||
begin: peak,
|
|
||||||
end: 0.0,
|
|
||||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
|
||||||
weight: 50,
|
|
||||||
),
|
|
||||||
]).animate(controller);
|
|
||||||
void onTick() {
|
|
||||||
if (scrollController.hasClients) {
|
|
||||||
scrollController.jumpTo(hint.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hint.addListener(onTick);
|
|
||||||
await controller.forward();
|
|
||||||
hint.removeListener(onTick);
|
|
||||||
_scrollHintControllers.remove(controller);
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
for (final controller in _scrollHintControllers) {
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
_selectionActionsScrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A favorited folder switches to the Files tab, navigated there; a
|
/// A favorited folder switches to the Files tab, navigated there; a
|
||||||
/// favorited file just opens directly from here, like Files/Photos do -
|
/// favorited file just opens directly from here, like Files/Photos do -
|
||||||
/// no need to reposition Files first since dismissing the viewer lands
|
/// no need to reposition Files first since dismissing the viewer lands
|
||||||
@@ -149,46 +101,57 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
/// The bulk actions shown in the sticky selection toolbar for the
|
/// The bulk actions shown in the sticky selection toolbar for the
|
||||||
/// currently-selected items - same set Files/Photos offer.
|
/// currently-selected items - same set Files/Photos offer.
|
||||||
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
|
List<SelectionAction> _buildSelectionActions(List<NextcloudItem> selected) {
|
||||||
return [
|
final actions = [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.favorite_border_rounded,
|
kind: SelectionActionKind.favorite,
|
||||||
|
icon: LucideIcons.starOff,
|
||||||
label: 'Remove from favorites',
|
label: 'Remove from favorites',
|
||||||
onTap: () => _unfavoriteSelected(selected),
|
onTap: () => _unfavoriteSelected(selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.share_rounded,
|
kind: SelectionActionKind.share,
|
||||||
|
icon: LucideIcons.share2,
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onTap: () => selected.length == 1
|
onTap: () => selected.length == 1
|
||||||
? ShareSheet.show(context, selected.single)
|
? ShareSheet.show(context, selected.single)
|
||||||
: _shareSelected(context, selected),
|
: _shareSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.download_rounded,
|
kind: SelectionActionKind.download,
|
||||||
|
icon: LucideIcons.download,
|
||||||
label: 'Download',
|
label: 'Download',
|
||||||
onTap: () => _downloadSelected(context, selected),
|
onTap: () => _downloadSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.delete_outline_rounded,
|
kind: SelectionActionKind.delete,
|
||||||
|
icon: LucideIcons.trash2,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onTap: () => _confirmDeleteSelected(context, selected),
|
onTap: () => _confirmDeleteSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.copy_rounded,
|
kind: SelectionActionKind.copy,
|
||||||
|
icon: LucideIcons.copy,
|
||||||
label: 'Copy',
|
label: 'Copy',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: true),
|
onTap: () => _moveOrCopySelected(selected, copy: true),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.drive_file_move_rounded,
|
kind: SelectionActionKind.move,
|
||||||
|
icon: LucideIcons.folderInput,
|
||||||
label: 'Move',
|
label: 'Move',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: false),
|
onTap: () => _moveOrCopySelected(selected, copy: false),
|
||||||
),
|
),
|
||||||
if (selected.length == 1)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.info_outline_rounded,
|
kind: SelectionActionKind.details,
|
||||||
|
icon: LucideIcons.info,
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
onTap: () => DetailsSheet.show(context, selected.single),
|
onTap: () => DetailsSheet.show(context, selected.single),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
return orderSelectionActions(
|
||||||
|
actions,
|
||||||
|
context.read<SettingsController>().selectionActionOrder,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
|
Future<void> _unfavoriteSelected(List<NextcloudItem> items) async {
|
||||||
@@ -348,174 +311,87 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildControlsRow(FilesController files) {
|
/// A single item's actions (the same list the multi-select bar offers,
|
||||||
return SizedBox(
|
/// just for one item) - mirrors `FilesView._showItemActionsSheet`.
|
||||||
height: 44,
|
void _showItemActionsSheet(BuildContext context, NextcloudItem item) {
|
||||||
child: SingleChildScrollView(
|
final actions = _buildSelectionActions([item]);
|
||||||
scrollDirection: Axis.horizontal,
|
showNooSheet(
|
||||||
child: Row(
|
context,
|
||||||
|
children: [
|
||||||
|
NooGroupedList(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
for (final action in actions)
|
||||||
icon: Icon(
|
NooSettingsRow(
|
||||||
files.filesSortAscending
|
icon: action.icon,
|
||||||
? Icons.arrow_upward_rounded
|
label: Text(action.label),
|
||||||
: Icons.arrow_downward_rounded,
|
onTap: () {
|
||||||
size: 20,
|
Navigator.pop(context);
|
||||||
|
action.onTap();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
|
|
||||||
onPressed: files.toggleFilesSortOrder,
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 130,
|
|
||||||
child: SortMenuButton(
|
|
||||||
field: files.filesSortField,
|
|
||||||
onChanged: files.setFilesSortField,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: files.showHiddenFiles
|
|
||||||
? Icons.visibility_rounded
|
|
||||||
: Icons.visibility_off_rounded,
|
|
||||||
isSelected: files.showHiddenFiles,
|
|
||||||
onTap: () => files.toggleShowHiddenFiles(),
|
|
||||||
tooltip: 'Show hidden files',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.circles_rounded,
|
|
||||||
isSelected: files.storageScope == StorageScope.cloud,
|
|
||||||
onTap: () => files.setStorageScope(StorageScope.cloud),
|
|
||||||
tooltip: 'Cloud storage',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.hard_drive_rounded,
|
|
||||||
isSelected: files.storageScope == StorageScope.external,
|
|
||||||
onTap: () => files.setStorageScope(StorageScope.external),
|
|
||||||
tooltip: 'External storage',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.view_list_rounded,
|
|
||||||
isSelected: !files.isGridView,
|
|
||||||
onTap: () => files.setGridView(false),
|
|
||||||
tooltip: 'List view',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.grid_view_rounded,
|
|
||||||
isSelected: files.isGridView,
|
|
||||||
onTap: () => files.setGridView(true),
|
|
||||||
tooltip: 'Grid view',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildListTile(
|
/// A real image/video thumbnail for [item]'s file tile/card, sized so
|
||||||
|
/// `NooFileTile`'s `FittedBox` (which needs a concretely-sized child to
|
||||||
|
/// scale) always has one to work with; null when there's nothing to show
|
||||||
|
/// (falls back to the kind's soft-color tile).
|
||||||
|
Widget? _thumbnailFor(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
NextcloudItem item,
|
NextcloudItem item, {
|
||||||
List<NextcloudItem> siblings,
|
required double extent,
|
||||||
) {
|
}) {
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final session = context.watch<SessionController>();
|
|
||||||
final isSelected = _selectedIds.contains(item.id);
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Material(
|
|
||||||
color: isSelected
|
|
||||||
? colorScheme.primaryContainer.withValues(alpha: 0.5)
|
|
||||||
: colorScheme.surfaceContainerLow,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
if (_isSelecting) {
|
|
||||||
_toggleSelection(item);
|
|
||||||
} else {
|
|
||||||
_openFavorite(context, item, siblings);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLongPress: () => _toggleSelection(item),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SelectableThumbnail(
|
|
||||||
isSelected: isSelected,
|
|
||||||
size: 44,
|
|
||||||
checkmarkSize: 24,
|
|
||||||
child: ItemThumbnail(
|
|
||||||
item: item,
|
|
||||||
service: session.service,
|
|
||||||
size: 44,
|
|
||||||
borderRadius: 12,
|
|
||||||
iconSize: 22,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
item.isFolder
|
|
||||||
? 'Folder'
|
|
||||||
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildGridCard(
|
|
||||||
BuildContext context,
|
|
||||||
NextcloudItem item,
|
|
||||||
List<NextcloudItem> siblings,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final session = context.watch<SessionController>();
|
|
||||||
final isSelected = _selectedIds.contains(item.id);
|
|
||||||
final isMedia =
|
final isMedia =
|
||||||
(item.type == NextcloudItemType.image ||
|
(item.type == NextcloudItemType.image ||
|
||||||
item.type == NextcloudItemType.video) &&
|
item.type == NextcloudItemType.video) &&
|
||||||
item.previewUrl != null;
|
item.previewUrl != null;
|
||||||
final iconColor = getIconColor(context, item.type);
|
if (!isMedia) return null;
|
||||||
|
final session = context.watch<SessionController>();
|
||||||
|
return Image.network(
|
||||||
|
item.previewUrl!,
|
||||||
|
width: extent,
|
||||||
|
height: extent,
|
||||||
|
headers: session.service?.authHeaders,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
filterQuality: FilterQuality.low,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (ctx, err, stack) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ClipRRect(
|
String _metaFor(NextcloudItem item) {
|
||||||
borderRadius: BorderRadius.circular(20),
|
return item.isFolder
|
||||||
child: Material(
|
? 'Folder'
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
: '${formatBytes(item.size)} · ${DateFormat.yMMMd().format(item.lastModified)}';
|
||||||
child: InkWell(
|
}
|
||||||
|
|
||||||
|
Widget _buildRow(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem item,
|
||||||
|
List<NextcloudItem> siblings,
|
||||||
|
) {
|
||||||
|
final isSelected = _selectedIds.contains(item.id);
|
||||||
|
final kind = NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
child: NooFileRow(
|
||||||
|
kind: kind,
|
||||||
|
name: item.name,
|
||||||
|
meta: _metaFor(item),
|
||||||
|
favorite: item.isFavorite,
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
selected: isSelected,
|
||||||
|
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_isSelecting) {
|
if (_isSelecting) {
|
||||||
_toggleSelection(item);
|
_toggleSelection(item);
|
||||||
@@ -524,66 +400,91 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: () => _toggleSelection(item),
|
onLongPress: () => _toggleSelection(item),
|
||||||
child: SelectableThumbnail(
|
onMore: () => _showItemActionsSheet(context, item),
|
||||||
isSelected: isSelected,
|
|
||||||
checkmarkSize: 32,
|
|
||||||
child: isMedia
|
|
||||||
? Image.network(
|
|
||||||
item.previewUrl!,
|
|
||||||
headers: session.service?.authHeaders,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
filterQuality: FilterQuality.low,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (ctx, err, stack) => Container(
|
|
||||||
color: theme.colorScheme.secondaryContainer,
|
|
||||||
child: Icon(
|
|
||||||
getItemIcon(item.type),
|
|
||||||
color: theme.colorScheme.onSecondaryContainer,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
getItemIcon(item.type),
|
|
||||||
color: iconColor,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildTableRow(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem item,
|
||||||
|
List<NextcloudItem> siblings,
|
||||||
|
) {
|
||||||
|
final isSelected = _selectedIds.contains(item.id);
|
||||||
|
final kind = NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NooFileTableRow(
|
||||||
|
kind: kind,
|
||||||
|
name: item.name,
|
||||||
|
col2: item.isFolder ? null : DateFormat.yMMMd().format(item.lastModified),
|
||||||
|
col3: item.isFolder ? null : formatBytes(item.size),
|
||||||
|
favorite: item.isFavorite,
|
||||||
|
selected: isSelected,
|
||||||
|
thumbnail: _thumbnailFor(
|
||||||
|
context,
|
||||||
|
item,
|
||||||
|
extent: NooFileTileSize.desktop.extent,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (_isSelecting) {
|
||||||
|
_toggleSelection(item);
|
||||||
|
} else {
|
||||||
|
_openFavorite(context, item, siblings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Desktop has no long-press gesture; right-click is this row's
|
||||||
|
// equivalent entry point into multi-select.
|
||||||
|
onSecondaryTap: () => _toggleSelection(item),
|
||||||
|
onMore: () => _showItemActionsSheet(context, item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGridCard(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem item,
|
||||||
|
List<NextcloudItem> siblings,
|
||||||
|
bool isDesktop,
|
||||||
|
) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final isSelected = _selectedIds.contains(item.id);
|
||||||
|
final kind = NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
);
|
||||||
|
final thumbnailHeight = isDesktop ? 118.0 : 104.0;
|
||||||
|
|
||||||
|
return NooGridCard(
|
||||||
|
name: item.name,
|
||||||
|
meta: item.isFolder ? 'Folder' : formatBytes(item.size),
|
||||||
|
placeholderColor: kind.background(colors),
|
||||||
|
icon: kind.icon,
|
||||||
|
iconColor: kind.foreground(colors),
|
||||||
|
thumbnailHeight: thumbnailHeight,
|
||||||
|
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
|
||||||
|
selected: isSelected,
|
||||||
|
verticalOverflowIcon: !NooLayout.iosStyle(context),
|
||||||
|
onTap: () {
|
||||||
|
if (_isSelecting) {
|
||||||
|
_toggleSelection(item);
|
||||||
|
} else {
|
||||||
|
_openFavorite(context, item, siblings);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _toggleSelection(item),
|
||||||
|
onMore: () => _showItemActionsSheet(context, item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
final favoritesController = context.watch<FavoritesController>();
|
final favoritesController = context.watch<FavoritesController>();
|
||||||
final filesController = context.watch<FilesController>();
|
final filesController = context.watch<FilesController>();
|
||||||
|
|
||||||
@@ -600,18 +501,27 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
.where((i) => _selectedIds.contains(i.id))
|
.where((i) => _selectedIds.contains(i.id))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final controlsRow = Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xs,
|
||||||
|
),
|
||||||
|
child: FilesControlsRow(folderPath: filesController.currentFolderPath),
|
||||||
|
);
|
||||||
|
|
||||||
final contentSlivers = <Widget>[
|
final contentSlivers = <Widget>[
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: !_isSelecting,
|
pinned: !_isSelecting,
|
||||||
delegate: StickyHeaderDelegate(
|
delegate: StickyHeaderDelegate(
|
||||||
height: 60,
|
height: _isSelecting ? 56 : 64,
|
||||||
child: Padding(
|
child: _isSelecting
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
? _buildSelectionBar(context, selectedItems)
|
||||||
child: _buildControlsRow(filesController),
|
: controlsRow,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
|
||||||
if (favoritesController.isLoading && favorites.isEmpty)
|
if (favoritesController.isLoading && favorites.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
@@ -626,32 +536,25 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
|
||||||
Icons.error_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Could not load favorites',
|
'Could not load favorites',
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: NooText.cardTitle.copyWith(color: colors.danger),
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
favoritesController.errorMessage!,
|
favoritesController.errorMessage!,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: NooText.body.copyWith(color: colors.fg3),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton.icon(
|
NooButton(
|
||||||
onPressed: favoritesController.fetchAll,
|
variant: NooButtonVariant.secondary,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
size: NooButtonSize.field,
|
||||||
label: const Text('Retry'),
|
icon: LucideIcons.refreshCw,
|
||||||
|
onTap: favoritesController.fetchAll,
|
||||||
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -665,17 +568,11 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.star, size: 56, color: colors.fg3),
|
||||||
Icons.favorite_border_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'No favorites yet',
|
'No favorites yet',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: NooText.cardTitle.copyWith(color: colors.fg2),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -683,30 +580,71 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
)
|
)
|
||||||
else if (filesController.isGridView)
|
else if (filesController.isGridView)
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: NooLayout.gutter(context),
|
||||||
|
vertical: NooSpace.xs,
|
||||||
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: isDesktop ? 5 : 2,
|
||||||
childAspectRatio: 1.1,
|
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||||
crossAxisSpacing: 12,
|
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||||
mainAxisSpacing: 12,
|
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
return _buildGridCard(context, favorites[index], siblings);
|
return _buildGridCard(
|
||||||
|
context,
|
||||||
|
favorites[index],
|
||||||
|
siblings,
|
||||||
|
isDesktop,
|
||||||
|
);
|
||||||
}, childCount: favorites.length),
|
}, childCount: favorites.length),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
else if (isDesktop)
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == 0) {
|
||||||
|
return NooFileTableHeader(
|
||||||
|
col2Label: 'Modified',
|
||||||
|
col3Label: 'Size',
|
||||||
|
sortColumn: switch (filesController.filesSortField) {
|
||||||
|
FileSortField.name => 0,
|
||||||
|
FileSortField.dateModified => 1,
|
||||||
|
FileSortField.size => 2,
|
||||||
|
FileSortField.dateCreated => null,
|
||||||
|
},
|
||||||
|
sortAscending: filesController.filesSortAscending,
|
||||||
|
onSort: (column) {
|
||||||
|
final field = [
|
||||||
|
FileSortField.name,
|
||||||
|
FileSortField.dateModified,
|
||||||
|
FileSortField.size,
|
||||||
|
][column];
|
||||||
|
if (field == filesController.filesSortField) {
|
||||||
|
filesController.toggleFilesSortOrder();
|
||||||
|
} else {
|
||||||
|
filesController.setFilesSortField(field);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildTableRow(context, favorites[index - 1], siblings);
|
||||||
|
}, childCount: favorites.length + 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
else
|
else
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: NooLayout.gutter(context)),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
return _buildListTile(context, favorites[index], siblings);
|
return _buildRow(context, favorites[index], siblings);
|
||||||
}, childCount: favorites.length),
|
}, childCount: favorites.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -714,74 +652,43 @@ class _FavoritesViewState extends State<FavoritesView>
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelecting) _clearSelection();
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
},
|
},
|
||||||
child: SyncedHeaderScaffold(
|
child: ColoredBox(
|
||||||
scrollController: widget.scrollController,
|
color: colors.bg,
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
child: RefreshIndicator(
|
||||||
onRefresh: favoritesController.fetchAll,
|
color: colors.accent,
|
||||||
selectionBar: _isSelecting
|
backgroundColor: colors.surface,
|
||||||
? _buildSelectionBar(context, theme, selectedItems)
|
onRefresh: favoritesController.fetchAll,
|
||||||
: null,
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
contentSlivers: contentSlivers,
|
// controls row rides up under the status bar once the floating
|
||||||
|
// top bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the top bar entirely while selecting (see
|
/// Replaces the controls row's own sticky slot while selecting - mirrors
|
||||||
/// `SyncedHeaderScaffold.selectionBar`) - mirrors Files/Photos' identical
|
/// Files/Photos' identical selection bar.
|
||||||
/// selection bar.
|
|
||||||
Widget _buildSelectionBar(
|
Widget _buildSelectionBar(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ThemeData theme,
|
|
||||||
List<NextcloudItem> selectedItems,
|
List<NextcloudItem> selectedItems,
|
||||||
) {
|
) {
|
||||||
return Padding(
|
return NooSelectionBar(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
count: selectedItems.length,
|
||||||
child: Row(
|
actions: _buildSelectionActions(selectedItems),
|
||||||
children: [
|
onClose: _clearSelection,
|
||||||
SizedBox(
|
isDesktop: NooLayout.isDesktop(context),
|
||||||
width: MediaQuery.of(context).size.width * 0.5,
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close_rounded, size: 20),
|
|
||||||
tooltip: 'Cancel selection',
|
|
||||||
onPressed: _clearSelection,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${selectedItems.length} selected',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
controller: _selectionActionsScrollController,
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
for (final action in _buildSelectionActions(selectedItems))
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(action.icon, size: 20),
|
|
||||||
tooltip: action.label,
|
|
||||||
onPressed: action.onTap,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
|
|
||||||
/// Shown in place of the main shell whenever [SessionController.needsUnlock] is
|
/// Shown in place of the main shell whenever [SessionController.needsUnlock] is
|
||||||
/// true - a fresh app launch with login lock configured, or returning to the
|
/// true - a fresh app launch with login lock configured, or returning to the
|
||||||
@@ -48,10 +51,10 @@ class _LockScreenViewState extends State<LockScreenView>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: colors.bg,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@@ -63,10 +66,7 @@ class _LockScreenViewState extends State<LockScreenView>
|
|||||||
// silhouette on transparent, meant to be recolored rather
|
// silhouette on transparent, meant to be recolored rather
|
||||||
// than shown as-is.
|
// than shown as-is.
|
||||||
ColorFiltered(
|
ColorFiltered(
|
||||||
colorFilter: ColorFilter.mode(
|
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
||||||
colorScheme.onSurface,
|
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/icon/app_icon_monochrome.png',
|
'assets/icon/app_icon_monochrome.png',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -77,35 +77,22 @@ class _LockScreenViewState extends State<LockScreenView>
|
|||||||
Text(
|
Text(
|
||||||
'Noo is locked',
|
'Noo is locked',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
style: NooText.title.copyWith(color: colors.fg1),
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Unlock with your PIN or biometrics to continue',
|
'Unlock with your PIN or biometrics to continue',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: NooText.body.copyWith(color: colors.fg3),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
NooButton(
|
||||||
height: 54,
|
variant: NooButtonVariant.primary,
|
||||||
child: FilledButton.icon(
|
size: NooButtonSize.cta,
|
||||||
onPressed: _authenticating ? null : _attemptUnlock,
|
icon: LucideIcons.lockOpen,
|
||||||
icon: _authenticating
|
disabled: _authenticating,
|
||||||
? SizedBox(
|
onTap: _attemptUnlock,
|
||||||
width: 20,
|
child: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.5,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.lock_open_rounded),
|
|
||||||
label: Text(_authenticating ? 'Unlocking…' : 'Unlock'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/saved_account.dart';
|
import '../models/saved_account.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import '../widgets/noo/core/noo_avatar.dart';
|
||||||
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/noo/overlays/noo_text_field.dart';
|
||||||
|
import '../widgets/settings/settings_dialogs.dart';
|
||||||
|
import '../widgets/shell/shell_common.dart';
|
||||||
import 'login_webview_view.dart';
|
import 'login_webview_view.dart';
|
||||||
|
|
||||||
class LoginView extends StatefulWidget {
|
class LoginView extends StatefulWidget {
|
||||||
/// True when this is pushed from Settings ("Add account") on top of an
|
/// True when this is pushed from Settings ("Add account") on top of an
|
||||||
/// already-logged-in session, rather than shown as the app's root screen
|
/// already-logged-in session, rather than shown as the app's root screen
|
||||||
/// with no account yet. Adds an AppBar/back affordance and auto-pops once
|
/// with no account yet. Adds a top bar/back affordance and auto-pops once
|
||||||
/// the new account becomes active.
|
/// the new account becomes active.
|
||||||
final bool isAddingAccount;
|
final bool isAddingAccount;
|
||||||
|
|
||||||
@@ -18,9 +27,9 @@ class LoginView extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LoginViewState extends State<LoginView> {
|
class _LoginViewState extends State<LoginView> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _urlController = TextEditingController();
|
final _urlController = TextEditingController();
|
||||||
String? _originalActiveAccountId;
|
String? _originalActiveAccountId;
|
||||||
|
String? _localError;
|
||||||
bool _popped = false;
|
bool _popped = false;
|
||||||
bool _webViewPushed = false;
|
bool _webViewPushed = false;
|
||||||
|
|
||||||
@@ -39,12 +48,14 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleContinue() {
|
void _handleContinue() {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
final url = _urlController.text.trim();
|
||||||
|
if (url.isEmpty) {
|
||||||
|
setState(() => _localError = 'Please enter your Nextcloud server address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _localError = null);
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
context.read<SessionController>().startLoginFlow(
|
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
|
||||||
_urlController.text.trim(),
|
|
||||||
addAccount: widget.isAddingAccount,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
|
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
|
||||||
@@ -68,47 +79,25 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors AccountView's `_AccountsCard._confirmRemove` - same
|
/// Mirrors Settings' own account removal - same confirm/remove gate, just
|
||||||
/// destructive-action gate, just reached from the login screen instead of
|
/// reached from the login screen instead of the saved-accounts list.
|
||||||
/// Settings.
|
|
||||||
Future<void> _confirmRemoveAccount(SavedAccount account) async {
|
Future<void> _confirmRemoveAccount(SavedAccount account) async {
|
||||||
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
|
await confirmRemoveAccount(
|
||||||
final confirmed = await showDialog<bool>(
|
context,
|
||||||
context: context,
|
context.read<SessionController>(),
|
||||||
builder: (dialogContext) {
|
accountId: account.id,
|
||||||
return AlertDialog(
|
username: account.username,
|
||||||
title: const Text('Remove Account'),
|
host: serverHost(account.serverUrl),
|
||||||
content: Text(
|
isActive: false,
|
||||||
'Remove ${account.username} ($host)? You can add it again later.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
|
||||||
child: const Text('Remove'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if (confirmed != true || !mounted) return;
|
|
||||||
await context.read<SessionController>().removeAccount(account.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
|
|
||||||
final isAwaitingBrowser =
|
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
||||||
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
|
||||||
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
|
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
|
||||||
|
|
||||||
// A new/refreshed account has just become active - pop back to
|
// A new/refreshed account has just become active - pop back to
|
||||||
@@ -128,9 +117,7 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
// SessionController.startLoginFlow's doc comment for why. Push it the
|
// SessionController.startLoginFlow's doc comment for why. Push it the
|
||||||
// moment there's a URL to show; _webViewPushed resets once that route
|
// moment there's a URL to show; _webViewPushed resets once that route
|
||||||
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
||||||
if (!_webViewPushed &&
|
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
|
||||||
isAwaitingBrowser &&
|
|
||||||
session.pendingLoginUrl != null) {
|
|
||||||
_webViewPushed = true;
|
_webViewPushed = true;
|
||||||
final url = session.pendingLoginUrl!;
|
final url = session.pendingLoginUrl!;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
@@ -154,9 +141,17 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final errorMessage =
|
||||||
|
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
|
||||||
|
|
||||||
final body = Scaffold(
|
final body = Scaffold(
|
||||||
|
backgroundColor: colors.bg,
|
||||||
appBar: widget.isAddingAccount
|
appBar: widget.isAddingAccount
|
||||||
? AppBar(title: const Text('Add Account'))
|
? NooTopBar(
|
||||||
|
style: NooLayout.navStyle(context),
|
||||||
|
title: 'Add account',
|
||||||
|
leading: const NooTopBarBack(),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -172,8 +167,7 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
// deleting it, specifically so it can be resumed
|
// deleting it, specifically so it can be resumed
|
||||||
// from here with one tap - no need to repeat
|
// from here with one tap - no need to repeat
|
||||||
// Login Flow v2.
|
// Login Flow v2.
|
||||||
if (!widget.isAddingAccount &&
|
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
|
||||||
session.accounts.isNotEmpty) ...[
|
|
||||||
_SavedAccountsSection(
|
_SavedAccountsSection(
|
||||||
accounts: session.accounts,
|
accounts: session.accounts,
|
||||||
onSelect: (account) => _continueAsAccount(account),
|
onSelect: (account) => _continueAsAccount(account),
|
||||||
@@ -182,33 +176,21 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: Divider()),
|
Expanded(child: Divider(color: colors.line)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text(
|
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
|
||||||
'or',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Expanded(child: Divider()),
|
Expanded(child: Divider(color: colors.line)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
],
|
],
|
||||||
_ServerForm(
|
_ServerForm(
|
||||||
formKey: _formKey,
|
|
||||||
urlController: _urlController,
|
urlController: _urlController,
|
||||||
isLoading:
|
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
|
||||||
isInitiating || session.isLoading || isAwaitingBrowser,
|
errorMessage: errorMessage,
|
||||||
errorMessage:
|
|
||||||
session.loginFlowStatus == LoginFlowStatus.error
|
|
||||||
? session.errorMessage
|
|
||||||
: null,
|
|
||||||
onContinue: _handleContinue,
|
onContinue: _handleContinue,
|
||||||
theme: theme,
|
|
||||||
colorScheme: colorScheme,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -236,157 +218,94 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ServerForm extends StatelessWidget {
|
class _ServerForm extends StatelessWidget {
|
||||||
final GlobalKey<FormState> formKey;
|
|
||||||
final TextEditingController urlController;
|
final TextEditingController urlController;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final VoidCallback onContinue;
|
final VoidCallback onContinue;
|
||||||
final ThemeData theme;
|
|
||||||
final ColorScheme colorScheme;
|
|
||||||
|
|
||||||
const _ServerForm({
|
const _ServerForm({
|
||||||
required this.formKey,
|
|
||||||
required this.urlController,
|
required this.urlController,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.errorMessage,
|
required this.errorMessage,
|
||||||
required this.onContinue,
|
required this.onContinue,
|
||||||
required this.theme,
|
|
||||||
required this.colorScheme,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Form(
|
final colors = context.nooColors;
|
||||||
key: formKey,
|
|
||||||
child: Column(
|
return Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
Center(
|
||||||
// Same monochrome-tinted treatment as the splash/lock screens
|
// Same monochrome-tinted treatment as the splash/lock screens
|
||||||
// (see main.dart's _SplashView) - the asset is a plain white
|
// (see main.dart's _SplashView) - the asset is a plain white
|
||||||
// silhouette on transparent, meant to be recolored rather than
|
// silhouette on transparent, meant to be recolored rather than
|
||||||
// shown as-is. Used everywhere the app shows its own icon
|
// shown as-is. Used everywhere the app shows its own icon
|
||||||
// in-app, rather than the full-color launcher icon.
|
// in-app, rather than the full-color launcher icon.
|
||||||
child: ColorFiltered(
|
child: ColorFiltered(
|
||||||
colorFilter: ColorFilter.mode(
|
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
||||||
Theme.of(context).colorScheme.onSurface,
|
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
|
||||||
BlendMode.srcIn,
|
|
||||||
),
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/icon/app_icon_monochrome.png',
|
|
||||||
width: 80,
|
|
||||||
height: 80,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
Text(
|
const SizedBox(height: 24),
|
||||||
'Noo',
|
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
|
||||||
textAlign: TextAlign.center,
|
const SizedBox(height: 6),
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
Text(
|
||||||
fontWeight: FontWeight.w800,
|
'Connect to your self-hosted server',
|
||||||
letterSpacing: -0.5,
|
textAlign: TextAlign.center,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 36),
|
||||||
|
if (errorMessage != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.dangerSoft,
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(height: 6),
|
children: [
|
||||||
Text(
|
Icon(LucideIcons.circleAlert, color: colors.danger, size: 20),
|
||||||
'Connect to your self-hosted server',
|
const SizedBox(width: 12),
|
||||||
textAlign: TextAlign.center,
|
Expanded(
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
child: Text(
|
||||||
color: colorScheme.onSurfaceVariant,
|
errorMessage!,
|
||||||
),
|
style: NooText.body.copyWith(
|
||||||
),
|
fontSize: 13,
|
||||||
const SizedBox(height: 36),
|
fontWeight: FontWeight.w500,
|
||||||
if (errorMessage != null)
|
color: colors.danger,
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.error_outline_rounded,
|
|
||||||
color: colorScheme.onErrorContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
errorMessage!,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onErrorContainer,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextFormField(
|
|
||||||
controller: urlController,
|
|
||||||
keyboardType: TextInputType.url,
|
|
||||||
autofillHints: const [AutofillHints.url],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Server Address',
|
|
||||||
hintText: 'cloud.example.com',
|
|
||||||
prefixIcon: const Icon(Icons.dns_outlined),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
filled: true,
|
|
||||||
fillColor: colorScheme.surfaceContainerLow,
|
|
||||||
),
|
|
||||||
validator: (val) {
|
|
||||||
if (val == null || val.trim().isEmpty) {
|
|
||||||
return 'Please enter your Nextcloud server address';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
onFieldSubmitted: (_) => onContinue(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
SizedBox(
|
|
||||||
height: 54,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: isLoading ? null : onContinue,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(27),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
child: isLoading
|
|
||||||
? SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.5,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'Continue',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
NooTextField(
|
||||||
Text(
|
controller: urlController,
|
||||||
"You'll finish signing in on the page that opens. This app never sees your password.",
|
placeholder: 'cloud.example.com',
|
||||||
textAlign: TextAlign.center,
|
leadingIcon: LucideIcons.server,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
keyboardType: TextInputType.url,
|
||||||
color: colorScheme.onSurfaceVariant,
|
onSubmitted: (_) => onContinue(),
|
||||||
fontSize: 11,
|
),
|
||||||
),
|
const SizedBox(height: 28),
|
||||||
),
|
NooButton(
|
||||||
],
|
variant: NooButtonVariant.primary,
|
||||||
),
|
size: NooButtonSize.cta,
|
||||||
|
fullWidth: true,
|
||||||
|
disabled: isLoading,
|
||||||
|
onTap: onContinue,
|
||||||
|
child: Text(isLoading ? 'Connecting…' : 'Continue'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
"You'll finish signing in on the page that opens. This app never sees your password.",
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: NooText.meta.copyWith(color: colors.fg3, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,44 +317,35 @@ class _SavedAccountsSection extends StatelessWidget {
|
|||||||
final ValueChanged<SavedAccount> onSelect;
|
final ValueChanged<SavedAccount> onSelect;
|
||||||
final ValueChanged<SavedAccount> onRemove;
|
final ValueChanged<SavedAccount> onRemove;
|
||||||
|
|
||||||
const _SavedAccountsSection({
|
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
|
||||||
required this.accounts,
|
|
||||||
required this.onSelect,
|
|
||||||
required this.onRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
child: Text(
|
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
|
||||||
'Continue as',
|
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Card(
|
ClipRRect(
|
||||||
margin: EdgeInsets.zero,
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
child: Column(
|
child: DecoratedBox(
|
||||||
children: [
|
decoration: BoxDecoration(color: colors.line),
|
||||||
for (final account in accounts) ...[
|
child: Column(
|
||||||
_SavedAccountRow(
|
children: [
|
||||||
account: account,
|
for (var i = 0; i < accounts.length; i++) ...[
|
||||||
onTap: () => onSelect(account),
|
if (i > 0) const SizedBox(height: 1),
|
||||||
onRemove: () => onRemove(account),
|
_SavedAccountRow(
|
||||||
),
|
account: accounts[i],
|
||||||
if (account != accounts.last)
|
onTap: () => onSelect(accounts[i]),
|
||||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
onRemove: () => onRemove(accounts[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -448,40 +358,60 @@ class _SavedAccountRow extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _SavedAccountRow({
|
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
|
||||||
required this.account,
|
|
||||||
required this.onTap,
|
|
||||||
required this.onRemove,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colors = context.nooColors;
|
||||||
final host = Uri.tryParse(account.serverUrl)?.host ?? account.serverUrl;
|
|
||||||
final initial = account.username.isNotEmpty
|
|
||||||
? account.username[0].toUpperCase()
|
|
||||||
: '?';
|
|
||||||
|
|
||||||
return ListTile(
|
return Material(
|
||||||
leading: CircleAvatar(
|
color: colors.surface,
|
||||||
backgroundColor: colorScheme.primary,
|
child: InkWell(
|
||||||
child: Text(
|
onTap: onTap,
|
||||||
initial,
|
child: Padding(
|
||||||
style: TextStyle(
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
||||||
color: colorScheme.onPrimary,
|
child: Row(
|
||||||
fontWeight: FontWeight.bold,
|
spacing: 12,
|
||||||
|
children: [
|
||||||
|
NooAvatar(initials: accountInitial(account.username), size: 36),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 2,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
account.username,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.bodyL.copyWith(
|
||||||
|
height: 1.1,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
serverHost(account.serverUrl),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onRemove,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
||||||
|
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(account.username),
|
|
||||||
subtitle: Text(host),
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(Icons.close_rounded),
|
|
||||||
tooltip: 'Remove account',
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
onPressed: onRemove,
|
|
||||||
),
|
|
||||||
onTap: onTap,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
|
||||||
/// Every Login Flow v2 page - first login or an additional account -
|
/// Every Login Flow v2 page - first login or an additional account -
|
||||||
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
|
/// rendered in Flutter's own WebView rather than a Chrome Custom Tab or
|
||||||
@@ -63,6 +67,7 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
|
final colors = context.nooColors;
|
||||||
|
|
||||||
// The login flow finished (success or error) or was cancelled
|
// The login flow finished (success or error) or was cancelled
|
||||||
// elsewhere - close this screen automatically rather than leaving it
|
// elsewhere - close this screen automatically rather than leaving it
|
||||||
@@ -81,18 +86,24 @@ class _LoginWebViewViewState extends State<LoginWebViewView> {
|
|||||||
if (!didPop) _cancel();
|
if (!didPop) _cancel();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: colors.bg,
|
||||||
title: const Text('Sign in'),
|
appBar: NooTopBar(
|
||||||
leading: IconButton(
|
style: NooLayout.navStyle(context),
|
||||||
icon: const Icon(Icons.close_rounded),
|
title: 'Log in',
|
||||||
|
leading: NooTopBarButton(
|
||||||
|
icon: LucideIcons.x,
|
||||||
tooltip: 'Cancel',
|
tooltip: 'Cancel',
|
||||||
onPressed: _cancel,
|
onTap: _cancel,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
WebViewWidget(controller: _controller),
|
WebViewWidget(controller: _controller),
|
||||||
if (_pageLoading) const LinearProgressIndicator(),
|
if (_pageLoading)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
color: colors.accent,
|
||||||
|
backgroundColor: colors.surface3,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/move_copy_result.dart';
|
import '../models/move_copy_result.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/files_controller.dart';
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/item_operations.dart';
|
import '../providers/item_operations.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/breadcrumbs.dart';
|
import '../widgets/breadcrumbs.dart';
|
||||||
import '../widgets/item_icon.dart';
|
import '../widgets/files_controls_row.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../widgets/more_tabs_button.dart';
|
||||||
import '../widgets/move_copy_conflict_sheet.dart';
|
import '../widgets/move_copy_conflict_sheet.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/segmented_icon_toggle.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
import '../widgets/sort_menu_button.dart';
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/noo/media/noo_grid_card.dart';
|
||||||
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
|
|
||||||
/// Destination-folder browser for moving/copying [items] (a multi-select
|
/// Destination-folder browser for moving/copying [items] (a multi-select
|
||||||
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
|
/// batch from Files or Photos). Visually mirrors `ShareUploadView`'s
|
||||||
@@ -85,9 +88,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
|||||||
|
|
||||||
Future<void> _fetch(String path) async {
|
Future<void> _fetch(String path) async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final raw = await context.read<FilesController>().fetchFolderListing(
|
final raw = await context.read<FilesController>().fetchFolderListing(path);
|
||||||
path,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_rawItems = raw;
|
_rawItems = raw;
|
||||||
@@ -175,352 +176,249 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
|||||||
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
|
).pop(MoveCopyResult(succeeded: succeeded, failed: failed));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildControlsRow(FilesController files) {
|
// Only folders are valid Move/Copy destinations, so the listing - unlike
|
||||||
return SizedBox(
|
// Files' own - never shows plain files at all.
|
||||||
height: 44,
|
Widget _buildRow(NextcloudItem item, int index, int count) {
|
||||||
child: SingleChildScrollView(
|
final colors = context.nooColors;
|
||||||
scrollDirection: Axis.horizontal,
|
final row = NooFileRow(
|
||||||
child: Row(
|
kind: NooFileKind.folder,
|
||||||
children: [
|
name: item.name,
|
||||||
IconButton(
|
meta: 'Folder',
|
||||||
icon: Icon(
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
files.filesSortAscending
|
onTap: () => _navigateToFolder(item.path),
|
||||||
? Icons.arrow_upward_rounded
|
);
|
||||||
: Icons.arrow_downward_rounded,
|
|
||||||
size: 20,
|
final isFirst = index == 0;
|
||||||
),
|
final isLast = index == count - 1;
|
||||||
visualDensity: VisualDensity.compact,
|
return Column(
|
||||||
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
|
children: [
|
||||||
onPressed: files.toggleFilesSortOrder,
|
ClipRRect(
|
||||||
),
|
borderRadius: BorderRadius.vertical(
|
||||||
SizedBox(
|
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
|
||||||
width: 130,
|
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
|
||||||
child: SortMenuButton(
|
),
|
||||||
field: files.filesSortField,
|
child: row,
|
||||||
onChanged: files.setFilesSortField,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: files.showHiddenFiles
|
|
||||||
? Icons.visibility_rounded
|
|
||||||
: Icons.visibility_off_rounded,
|
|
||||||
isSelected: files.showHiddenFiles,
|
|
||||||
onTap: () => files.toggleShowHiddenFiles(),
|
|
||||||
tooltip: 'Show hidden files',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.circles_rounded,
|
|
||||||
isSelected: files.storageScope == StorageScope.cloud,
|
|
||||||
onTap: () => files.setStorageScope(StorageScope.cloud),
|
|
||||||
tooltip: 'Cloud storage',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.hard_drive_rounded,
|
|
||||||
isSelected: files.storageScope == StorageScope.external,
|
|
||||||
onTap: () => files.setStorageScope(StorageScope.external),
|
|
||||||
tooltip: 'External storage',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.view_list_rounded,
|
|
||||||
isSelected: !files.isGridView,
|
|
||||||
onTap: () => files.setGridView(false),
|
|
||||||
tooltip: 'List view',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.grid_view_rounded,
|
|
||||||
isSelected: files.isGridView,
|
|
||||||
onTap: () => files.setGridView(true),
|
|
||||||
tooltip: 'Grid view',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (!isLast) Container(height: 1, color: colors.line),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only folders are valid Move/Copy destinations - files still show (so
|
Widget _buildDesktopRow(NextcloudItem item) {
|
||||||
// the listing matches what the Files tab itself would show for this
|
return NooFileTableRow(
|
||||||
// folder) but are visually dimmed and inert, same treatment as
|
kind: NooFileKind.folder,
|
||||||
// ShareUploadView's own destination browser.
|
name: item.name,
|
||||||
Widget _buildListTile(NextcloudItem item) {
|
onTap: () => _navigateToFolder(item.path),
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final session = context.watch<SessionController>();
|
|
||||||
final isFolder = item.isFolder;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Opacity(
|
|
||||||
opacity: isFolder ? 1 : 0.5,
|
|
||||||
child: Material(
|
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ItemThumbnail(
|
|
||||||
item: item,
|
|
||||||
service: session.service,
|
|
||||||
size: 44,
|
|
||||||
borderRadius: 12,
|
|
||||||
iconSize: 22,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
isFolder
|
|
||||||
? 'Folder'
|
|
||||||
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isFolder) const Icon(Icons.chevron_right_rounded),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridCard(NextcloudItem item) {
|
Widget _buildGridCard(NextcloudItem item) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
return NooGridCard(
|
||||||
final isFolder = item.isFolder;
|
name: item.name,
|
||||||
final iconColor = getIconColor(context, item.type);
|
meta: 'Folder',
|
||||||
|
placeholderColor: colors.accentSoft,
|
||||||
return Opacity(
|
icon: LucideIcons.folder,
|
||||||
opacity: isFolder ? 1 : 0.5,
|
iconColor: colors.accentText,
|
||||||
child: Material(
|
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
|
||||||
color: colorScheme.surfaceContainerLow,
|
onTap: () => _navigateToFolder(item.path),
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: isFolder ? () => _navigateToFolder(item.path) : null,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
getItemIcon(item.type),
|
|
||||||
color: iconColor,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
isFolder ? 'Folder' : formatBytes(item.size),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
final desktop = NooLayout.isDesktop(context);
|
||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final ops = context.read<ItemOperations>();
|
final ops = context.read<ItemOperations>();
|
||||||
final hasBreadcrumbs = _pathStack.length > 1;
|
final hasBreadcrumbs = _pathStack.length > 1;
|
||||||
final currentLabel = _currentPath == '/'
|
final folders = files
|
||||||
? 'Home'
|
.applyFilesDisplayPrefs(_rawItems)
|
||||||
: _currentPath.split('/').where((s) => s.isNotEmpty).last;
|
.where((item) => item.isFolder)
|
||||||
final items = files.applyFilesDisplayPrefs(_rawItems);
|
.toList();
|
||||||
final invalidDestination = _destinationIsInvalid();
|
final invalidDestination = _destinationIsInvalid();
|
||||||
|
final gutter = NooLayout.gutter(context);
|
||||||
|
final verb = widget.copy ? 'Copying' : 'Moving';
|
||||||
|
final itemCount = widget.items.length;
|
||||||
|
|
||||||
final controlsColumn = Padding(
|
final slivers = <Widget>[
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
SliverToBoxAdapter(
|
||||||
child: Column(
|
child: Padding(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: EdgeInsets.fromLTRB(
|
||||||
children: [
|
gutter,
|
||||||
_buildControlsRow(files),
|
NooSpace.md,
|
||||||
if (hasBreadcrumbs) ...[
|
gutter,
|
||||||
const SizedBox(height: 10),
|
NooSpace.sm,
|
||||||
SizedBox(
|
),
|
||||||
height: 32,
|
child: Column(
|
||||||
child: Breadcrumbs(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
pathStack: _pathStack,
|
children: [
|
||||||
onTap: _navigateToPathIndex,
|
SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: FilesControlsRow(folderPath: _currentPath),
|
||||||
),
|
),
|
||||||
),
|
if (hasBreadcrumbs) ...[
|
||||||
],
|
const SizedBox(height: 10),
|
||||||
],
|
SizedBox(
|
||||||
),
|
height: 32,
|
||||||
);
|
child: Breadcrumbs(
|
||||||
|
pathStack: _pathStack,
|
||||||
final contentSlivers = <Widget>[
|
onTap: _navigateToPathIndex,
|
||||||
SliverPersistentHeader(
|
),
|
||||||
pinned: true,
|
),
|
||||||
delegate: StickyHeaderDelegate(
|
],
|
||||||
height: hasBreadcrumbs ? 114 : 72,
|
],
|
||||||
child: controlsColumn,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
const SliverFillRemaining(
|
tabLoadingSliver
|
||||||
hasScrollBody: false,
|
else if (folders.isEmpty)
|
||||||
child: Center(child: CircularProgressIndicator()),
|
tabEmptySliver(
|
||||||
)
|
context,
|
||||||
else if (items.isEmpty)
|
icon: LucideIcons.folder,
|
||||||
SliverFillRemaining(
|
message: 'No folders here',
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'Folder is empty',
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else if (files.isGridView)
|
else if (files.isGridView)
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: desktop ? 5 : 2,
|
||||||
childAspectRatio: 1.1,
|
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: desktop ? 16 : 10,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: desktop ? 16 : 10,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildGridCard(folders[index]),
|
||||||
|
childCount: folders.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (desktop)
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildDesktopRow(folders[index]),
|
||||||
|
childCount: folders.length,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
return _buildGridCard(items[index]);
|
|
||||||
}, childCount: items.length),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate(
|
||||||
return _buildListTile(items[index]);
|
(context, index) =>
|
||||||
}, childCount: items.length),
|
_buildRow(folders[index], index, folders.length),
|
||||||
|
childCount: folders.length,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final title = widget.copy ? 'Copy to...' : 'Move to...';
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: _pathStack.length <= 1,
|
canPop: _pathStack.length <= 1,
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop) _navigateUp();
|
if (!didPop) _navigateUp();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: SyncedHeaderScaffold(
|
backgroundColor: colors.bg,
|
||||||
scrollController: _scrollController,
|
appBar: desktop
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
? NooToolbar(
|
||||||
contentSlivers: contentSlivers,
|
title: title,
|
||||||
|
actions: [
|
||||||
|
const MoreTabsButton(),
|
||||||
|
const SizedBox(width: NooSpace.xs),
|
||||||
|
NooButton(
|
||||||
|
variant: NooButtonVariant.secondary,
|
||||||
|
size: NooButtonSize.toolbar,
|
||||||
|
onTap: () => Navigator.maybePop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: NooTopBar(
|
||||||
|
style: NooLayout.navStyle(context),
|
||||||
|
title: title,
|
||||||
|
leading: const NooTopBarBack(label: 'Cancel'),
|
||||||
|
// Stacks a hidden tab's own screen on top rather than
|
||||||
|
// jumping the shell there, so it doesn't abandon this
|
||||||
|
// picker - safe to keep, unlike ShareUploadView's chrome.
|
||||||
|
actions: const [MoreTabsButton()],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
color: colors.accent,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
onRefresh: () => _fetch(_currentPath),
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-
|
||||||
|
// to-refresh can't be triggered on an empty or single-folder
|
||||||
|
// listing.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: slivers,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
bottomNavigationBar: Material(
|
bottomNavigationBar: DecoratedBox(
|
||||||
color: colorScheme.surfaceContainerHigh,
|
decoration: BoxDecoration(
|
||||||
elevation: 8,
|
color: colors.surface,
|
||||||
shape: const RoundedRectangleBorder(
|
border: Border(top: BorderSide(color: colors.line)),
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.lg,
|
||||||
|
NooSpace.md,
|
||||||
|
NooSpace.lg,
|
||||||
|
NooSpace.md,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
if (invalidDestination) ...[
|
Text(
|
||||||
Text(
|
invalidDestination
|
||||||
"Can't ${widget.copy ? 'copy' : 'move'} a folder into "
|
? "Can't ${widget.copy ? 'copy' : 'move'} a folder into "
|
||||||
"itself or one of its own subfolders",
|
"itself or one of its own subfolders"
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
: '$verb $itemCount item${itemCount == 1 ? '' : 's'}',
|
||||||
color: colorScheme.error,
|
textAlign: TextAlign.center,
|
||||||
),
|
style: NooText.meta.copyWith(
|
||||||
|
color: invalidDestination ? colors.danger : colors.fg3,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
],
|
const SizedBox(height: NooSpace.sm),
|
||||||
SizedBox(
|
NooButton(
|
||||||
width: double.infinity,
|
variant: NooButtonVariant.primary,
|
||||||
child: FilledButton.icon(
|
size: NooButtonSize.cta,
|
||||||
onPressed: invalidDestination || _isSubmitting
|
fullWidth: true,
|
||||||
? null
|
disabled: invalidDestination || _isSubmitting,
|
||||||
: () => _confirm(ops),
|
icon: _isSubmitting
|
||||||
icon: _isSubmitting
|
? null
|
||||||
? const SizedBox(
|
: (widget.copy
|
||||||
width: 18,
|
? LucideIcons.copy
|
||||||
height: 18,
|
: LucideIcons.folderInput),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
onTap: invalidDestination || _isSubmitting
|
||||||
)
|
? null
|
||||||
: Icon(
|
: () => _confirm(ops),
|
||||||
widget.copy
|
child: _isSubmitting
|
||||||
? Icons.copy_rounded
|
? const SizedBox(
|
||||||
: Icons.drive_file_move_rounded,
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
label: Text(
|
)
|
||||||
'${widget.copy ? 'Copy' : 'Move'} ${widget.items.length} '
|
: Text(widget.copy ? 'Copy here' : 'Move here'),
|
||||||
'item(s) to $currentLabel',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
@@ -10,107 +11,82 @@ import '../providers/item_operations.dart';
|
|||||||
import '../providers/photos_controller.dart';
|
import '../providers/photos_controller.dart';
|
||||||
import '../providers/pick_controller.dart';
|
import '../providers/pick_controller.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/noo/core/noo_chip.dart';
|
||||||
import '../widgets/segmented_icon_toggle.dart';
|
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||||
import '../widgets/selectable_thumbnail.dart';
|
import '../widgets/noo/core/noo_toggle.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
|
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||||
|
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||||
|
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||||
|
import '../widgets/noo/media/noo_photo_group.dart';
|
||||||
|
import '../widgets/noo/media/noo_photo_tile.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sort_menu_button.dart';
|
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
|
|
||||||
|
/// The type chips PhotosController actually has data for (DESIGN_SYSTEM.md
|
||||||
|
/// section 4 also lists "Camera", but there's no camera-upload/EXIF source
|
||||||
|
/// to distinguish that from any other photo - see the view's doc comment).
|
||||||
|
enum _PhotoTypeFilter { all, image, video }
|
||||||
|
|
||||||
|
/// A contiguous run of [PhotosController.items] that shares a calendar
|
||||||
|
/// month, in whatever order the controller already sorted them - see
|
||||||
|
/// [_PhotosViewState._groupByMonth].
|
||||||
|
class _MonthGroup {
|
||||||
|
final DateTime month;
|
||||||
|
final List<NextcloudItem> items;
|
||||||
|
_MonthGroup(this.month, this.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Photos tab: every image/video across the account, grouped by month
|
||||||
|
/// (DESIGN_SYSTEM.md section 4). Renders content only - the shell
|
||||||
|
/// (`MainShellView`) owns the top bar, bottom bar, drawer and FAB.
|
||||||
class PhotosView extends StatefulWidget {
|
class PhotosView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const PhotosView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const PhotosView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PhotosView> createState() => _PhotosViewState();
|
State<PhotosView> createState() => _PhotosViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PhotosViewState extends State<PhotosView>
|
class _PhotosViewState extends State<PhotosView> {
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
bool _requested = false;
|
bool _requested = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
final ScrollController _selectionActionsScrollController = ScrollController();
|
|
||||||
final List<AnimationController> _scrollHintControllers = [];
|
// Session-local only (not one of PhotosController's persisted display
|
||||||
|
// prefs) - a pure narrowing of the already-fetched/filtered/sorted list,
|
||||||
|
// same way the type chips narrow Files' listing without touching the
|
||||||
|
// controller.
|
||||||
|
_PhotoTypeFilter _typeFilter = _PhotoTypeFilter.all;
|
||||||
|
|
||||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||||
|
|
||||||
void _toggleSelection(NextcloudItem item) {
|
void _toggleSelection(NextcloudItem item) {
|
||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
final enteringSelection = _selectedIds.isEmpty;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
if (enteringSelection && _isSelecting) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
|
||||||
(_) => _playScrollHint(_selectionActionsScrollController),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A one-shot hint that the selection actions row actually scrolls -
|
|
||||||
/// mirrors `FilesView`'s identical controls-row hint (see its doc
|
|
||||||
/// comment): nudges it right and back, once, the first time a selection
|
|
||||||
/// starts. No-ops if there's nothing to scroll (row already fits).
|
|
||||||
Future<void> _playScrollHint(ScrollController scrollController) async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
if (!mounted || !scrollController.hasClients) return;
|
|
||||||
final maxExtent = scrollController.position.maxScrollExtent;
|
|
||||||
if (maxExtent <= 0) return;
|
|
||||||
final double peak = maxExtent < 36 ? maxExtent : 36;
|
|
||||||
final controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 900),
|
|
||||||
);
|
|
||||||
_scrollHintControllers.add(controller);
|
|
||||||
final hint = TweenSequence<double>([
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween(
|
|
||||||
begin: 0.0,
|
|
||||||
end: peak,
|
|
||||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
|
||||||
weight: 50,
|
|
||||||
),
|
|
||||||
TweenSequenceItem(
|
|
||||||
tween: Tween(
|
|
||||||
begin: peak,
|
|
||||||
end: 0.0,
|
|
||||||
).chain(CurveTween(curve: Curves.easeInOutSine)),
|
|
||||||
weight: 50,
|
|
||||||
),
|
|
||||||
]).animate(controller);
|
|
||||||
void onTick() {
|
|
||||||
if (scrollController.hasClients) {
|
|
||||||
scrollController.jumpTo(hint.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hint.addListener(onTick);
|
|
||||||
await controller.forward();
|
|
||||||
hint.removeListener(onTick);
|
|
||||||
_scrollHintControllers.remove(controller);
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
for (final controller in _scrollHintControllers) {
|
|
||||||
controller.dispose();
|
|
||||||
}
|
|
||||||
_selectionActionsScrollController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
||||||
/// just the matching/toggle/immediate-confirm branch.
|
/// just the matching/toggle/immediate-confirm branch.
|
||||||
void _handlePickTap(
|
void _handlePickTap(
|
||||||
@@ -143,62 +119,103 @@ class _PhotosViewState extends State<PhotosView>
|
|||||||
if (pick.isPicking) {
|
if (pick.isPicking) {
|
||||||
return [
|
return [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.check_rounded,
|
kind: SelectionActionKind.favorite,
|
||||||
|
icon: LucideIcons.check,
|
||||||
label: 'Use ${selected.length} item(s)',
|
label: 'Use ${selected.length} item(s)',
|
||||||
onTap: () => pick.confirmPick(selected),
|
onTap: () => pick.confirmPick(selected),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
// Matches the star/star-off convention Files' own selection toolbar
|
||||||
|
// uses for favorite/unfavorite (see `files_view.dart`) rather than a
|
||||||
|
// filled heart.
|
||||||
|
final allFavorited = selected.every((i) => i.isFavorite);
|
||||||
|
final actions = [
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: selected.every((i) => i.isFavorite)
|
kind: SelectionActionKind.favorite,
|
||||||
? Icons.favorite_rounded
|
icon: allFavorited ? LucideIcons.starOff : LucideIcons.star,
|
||||||
: Icons.favorite_border_rounded,
|
label: allFavorited ? 'Remove from favorites' : 'Favorite',
|
||||||
label: selected.every((i) => i.isFavorite)
|
|
||||||
? 'Remove from favorites'
|
|
||||||
: 'Favorite',
|
|
||||||
onTap: () => _favoriteSelected(selected),
|
onTap: () => _favoriteSelected(selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.share_rounded,
|
kind: SelectionActionKind.share,
|
||||||
|
icon: LucideIcons.share2,
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
onTap: () => selected.length == 1
|
onTap: () => selected.length == 1
|
||||||
? ShareSheet.show(context, selected.single)
|
? ShareSheet.show(context, selected.single)
|
||||||
: _shareSelected(context, selected),
|
: _shareSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.download_rounded,
|
kind: SelectionActionKind.download,
|
||||||
|
icon: LucideIcons.download,
|
||||||
label: 'Download',
|
label: 'Download',
|
||||||
onTap: () => _downloadSelected(context, selected),
|
onTap: () => _downloadSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.delete_outline_rounded,
|
kind: SelectionActionKind.delete,
|
||||||
|
icon: LucideIcons.trash2,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onTap: () => _confirmDeleteSelected(context, selected),
|
onTap: () => _confirmDeleteSelected(context, selected),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.copy_rounded,
|
kind: SelectionActionKind.copy,
|
||||||
|
icon: LucideIcons.copy,
|
||||||
label: 'Copy',
|
label: 'Copy',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: true),
|
onTap: () => _moveOrCopySelected(selected, copy: true),
|
||||||
),
|
),
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.drive_file_move_rounded,
|
kind: SelectionActionKind.move,
|
||||||
|
icon: LucideIcons.folderInput,
|
||||||
label: 'Move',
|
label: 'Move',
|
||||||
onTap: () => _moveOrCopySelected(selected, copy: false),
|
onTap: () => _moveOrCopySelected(selected, copy: false),
|
||||||
),
|
),
|
||||||
if (selected.length == 1)
|
if (selected.length == 1)
|
||||||
SelectionAction(
|
SelectionAction(
|
||||||
icon: Icons.info_outline_rounded,
|
kind: SelectionActionKind.details,
|
||||||
|
icon: LucideIcons.info,
|
||||||
label: 'Details',
|
label: 'Details',
|
||||||
onTap: () => DetailsSheet.show(context, selected.single),
|
onTap: () => DetailsSheet.show(context, selected.single),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
return orderSelectionActions(
|
||||||
|
actions,
|
||||||
|
context.read<SettingsController>().selectionActionOrder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits an already-filtered/sorted list into contiguous month runs
|
||||||
|
/// (`dateCreated`) in list order - deliberately not a global re-sort by
|
||||||
|
/// date, so a "Name"/"Size" sort chip still governs the order shown
|
||||||
|
/// (see the group header row it feeds).
|
||||||
|
List<_MonthGroup> _groupByMonth(List<NextcloudItem> items) {
|
||||||
|
final groups = <_MonthGroup>[];
|
||||||
|
for (final item in items) {
|
||||||
|
final d = item.dateCreated;
|
||||||
|
final month = DateTime(d.year, d.month);
|
||||||
|
if (groups.isNotEmpty && groups.last.month == month) {
|
||||||
|
groups.last.items.add(item);
|
||||||
|
} else {
|
||||||
|
groups.add(_MonthGroup(month, [item]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesTypeFilter(NextcloudItem item) {
|
||||||
|
switch (_typeFilter) {
|
||||||
|
case _PhotoTypeFilter.all:
|
||||||
|
return true;
|
||||||
|
case _PhotoTypeFilter.image:
|
||||||
|
return item.type == NextcloudItemType.image;
|
||||||
|
case _PhotoTypeFilter.video:
|
||||||
|
return item.type == NextcloudItemType.video;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
final photosController = context.watch<PhotosController>();
|
final photosController = context.watch<PhotosController>();
|
||||||
final filesController = context.watch<FilesController>();
|
final filesController = context.watch<FilesController>();
|
||||||
final pick = context.watch<PickController>();
|
final pick = context.watch<PickController>();
|
||||||
@@ -210,89 +227,45 @@ class _PhotosViewState extends State<PhotosView>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final photos = photosController.items;
|
final allPhotos = photosController.items;
|
||||||
final selectedItems = photos
|
final visiblePhotos = allPhotos.where(_matchesTypeFilter).toList();
|
||||||
|
// Selection tracks the full (favorites/hidden-filtered) list, not just
|
||||||
|
// the type-filtered view, so switching the type chip mid-selection
|
||||||
|
// never silently drops a selected item from the bulk actions.
|
||||||
|
final selectedItems = allPhotos
|
||||||
.where((i) => _selectedIds.contains(i.id))
|
.where((i) => _selectedIds.contains(i.id))
|
||||||
.toList();
|
.toList();
|
||||||
|
final groups = _groupByMonth(visiblePhotos);
|
||||||
|
|
||||||
final controlsRow = Padding(
|
final controlsRow = _buildControlsRow(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
context,
|
||||||
child: SizedBox(
|
photosController,
|
||||||
height: 44,
|
filesController,
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
photosController.sortAscending
|
|
||||||
? Icons.arrow_upward_rounded
|
|
||||||
: Icons.arrow_downward_rounded,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
tooltip: photosController.sortAscending
|
|
||||||
? 'Ascending'
|
|
||||||
: 'Descending',
|
|
||||||
onPressed: photosController.toggleSortOrder,
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: SortMenuButton(
|
|
||||||
field: photosController.sortField,
|
|
||||||
onChanged: photosController.setSortField,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: photosController.showFavoritesOnly
|
|
||||||
? Icons.favorite_rounded
|
|
||||||
: Icons.favorite_border_rounded,
|
|
||||||
isSelected: photosController.showFavoritesOnly,
|
|
||||||
onTap: photosController.toggleFavoritesFilter,
|
|
||||||
tooltip: 'Favorites only',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: photosController.showHidden
|
|
||||||
? Icons.visibility_rounded
|
|
||||||
: Icons.visibility_off_rounded,
|
|
||||||
isSelected: photosController.showHidden,
|
|
||||||
onTap: () => photosController.toggleShowHidden(),
|
|
||||||
tooltip: 'Show hidden files',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.circles_rounded,
|
|
||||||
isSelected: filesController.storageScope == StorageScope.cloud,
|
|
||||||
onTap: () =>
|
|
||||||
filesController.setStorageScope(StorageScope.cloud),
|
|
||||||
tooltip: 'Cloud storage',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.hard_drive_rounded,
|
|
||||||
isSelected:
|
|
||||||
filesController.storageScope == StorageScope.external,
|
|
||||||
onTap: () =>
|
|
||||||
filesController.setStorageScope(StorageScope.external),
|
|
||||||
tooltip: 'External storage',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Sticky while browsing; once selecting, the selection bar takes over
|
// Sticky while browsing; once selecting, the selection bar takes over
|
||||||
// the very top of the screen instead (see `selectionBar` below), so
|
// the same slot instead.
|
||||||
// this is free to scroll away rather than staying pinned under it.
|
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: !_isSelecting,
|
pinned: !_isSelecting,
|
||||||
delegate: StickyHeaderDelegate(height: 60, child: controlsRow),
|
delegate: StickyHeaderDelegate(
|
||||||
|
height: _isSelecting ? 56 : 64,
|
||||||
|
child: _isSelecting
|
||||||
|
? _buildSelectionBar(context, pick, selectedItems)
|
||||||
|
: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xs,
|
||||||
|
),
|
||||||
|
child: controlsRow,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
|
||||||
|
|
||||||
if (photosController.isLoading && photos.isEmpty)
|
if (photosController.isLoading && allPhotos.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
@@ -306,85 +279,83 @@ class _PhotosViewState extends State<PhotosView>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.circleAlert, size: 56, color: colors.danger),
|
||||||
Icons.error_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Could not load photos',
|
'Could not load photos',
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: NooText.cardTitle.copyWith(color: colors.danger),
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
photosController.errorMessage!,
|
photosController.errorMessage!,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: NooText.body.copyWith(color: colors.fg3),
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton.icon(
|
NooButton(
|
||||||
onPressed: photosController.fetchAllMedia,
|
variant: NooButtonVariant.secondary,
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
size: NooButtonSize.field,
|
||||||
label: const Text('Retry'),
|
icon: LucideIcons.refreshCw,
|
||||||
|
onTap: photosController.fetchAllMedia,
|
||||||
|
child: const Text('Retry'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (photos.isEmpty)
|
else if (visiblePhotos.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.image, size: 56, color: colors.fg3),
|
||||||
Icons.photo_library_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'No photos found in Nextcloud library',
|
allPhotos.isEmpty
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
? 'No photos found in Nextcloud library'
|
||||||
color: colorScheme.onSurfaceVariant,
|
: 'No photos match this filter',
|
||||||
),
|
style: NooText.cardTitle.copyWith(color: colors.fg2),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SliverPadding(
|
for (final group in groups) ...[
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
SliverToBoxAdapter(
|
||||||
sliver: SliverGrid(
|
child: NooPhotoGroupHeader(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
title: DateFormat('MMMM yyyy').format(group.month),
|
||||||
crossAxisCount: 3,
|
count:
|
||||||
crossAxisSpacing: 8,
|
'${group.items.length} ${group.items.length == 1 ? 'item' : 'items'}',
|
||||||
mainAxisSpacing: 8,
|
padding: EdgeInsets.fromLTRB(
|
||||||
childAspectRatio: 1.0,
|
NooLayout.gutter(context),
|
||||||
|
NooSpace.lg,
|
||||||
|
NooLayout.gutter(context),
|
||||||
|
NooSpace.sm,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final photo = photos[index];
|
|
||||||
return _buildPhotoTile(context, photo);
|
|
||||||
}, childCount: photos.length),
|
|
||||||
),
|
),
|
||||||
),
|
NooPhotoGrid(
|
||||||
|
itemCount: group.items.length,
|
||||||
|
columns: isDesktop
|
||||||
|
? NooPhotoGrid.desktopColumns
|
||||||
|
: NooPhotoGrid.mobileColumns,
|
||||||
|
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_buildPhotoTile(context, group.items[index], visiblePhotos),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
// Fixed clearance so the last row isn't hidden behind the floating
|
// Clearance so the last row isn't hidden behind the nav bar,
|
||||||
// nav bar, regardless of grid length.
|
// regardless of grid length - see `bottomBarClearance`'s own doc
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
// comment for why this has to be dynamic rather than a flat 100.
|
||||||
// For a short grid this stretches the white card's background down to
|
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||||
// the screen edge (matching the empty state above); for a long grid
|
|
||||||
// that already fills the viewport it contributes nothing extra.
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -392,196 +363,319 @@ class _PhotosViewState extends State<PhotosView>
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelecting) _clearSelection();
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
},
|
},
|
||||||
child: SyncedHeaderScaffold(
|
child: ColoredBox(
|
||||||
scrollController: widget.scrollController,
|
color: colors.bg,
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
child: RefreshIndicator(
|
||||||
onRefresh: photosController.fetchAllMedia,
|
color: colors.accent,
|
||||||
selectionBar: _isSelecting
|
backgroundColor: colors.surface,
|
||||||
? _buildSelectionBar(context, theme, pick, selectedItems)
|
onRefresh: photosController.fetchAllMedia,
|
||||||
: null,
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
contentSlivers: contentSlivers,
|
// controls row rides up under the status bar once the floating
|
||||||
|
// top bar above it fully collapses.
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replaces the top bar entirely while selecting (see
|
/// Type chips (All/Photos/Videos - "Camera" from the spec has no data
|
||||||
/// `SyncedHeaderScaffold.selectionBar`) - a close button, the "N
|
/// source, see the view's doc comment), a sort chip and a filter chip for
|
||||||
/// selected" count, and the horizontally-scrollable bulk actions.
|
/// favorites-only/hidden/external-storage, mirroring `FilesControlsRow`'s
|
||||||
Widget _buildSelectionBar(
|
/// layout/pattern.
|
||||||
|
Widget _buildControlsRow(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
ThemeData theme,
|
PhotosController photos,
|
||||||
PickController pick,
|
FilesController files,
|
||||||
List<NextcloudItem> selectedItems,
|
|
||||||
) {
|
) {
|
||||||
return Padding(
|
final filtersActive =
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
_typeFilter != _PhotoTypeFilter.all ||
|
||||||
|
photos.showFavoritesOnly ||
|
||||||
|
photos.showHidden ||
|
||||||
|
files.storageScope != StorageScope.cloud;
|
||||||
|
|
||||||
|
// A plain Row, not a horizontally-scrolling one - see
|
||||||
|
// `FilesControlsRow`'s identical fix/doc comment: a `SingleChildScrollView`
|
||||||
|
// gives its child unbounded width for no benefit here (two chips never
|
||||||
|
// need to scroll), and it's actively harmful for a row with a trailing
|
||||||
|
// `Spacer`/flex child.
|
||||||
|
return SizedBox(
|
||||||
|
height: 44,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
NooChip(
|
||||||
width: MediaQuery.of(context).size.width * 0.5,
|
icon: photos.sortAscending
|
||||||
child: Align(
|
? LucideIcons.arrowUp
|
||||||
alignment: Alignment.centerLeft,
|
: LucideIcons.arrowDown,
|
||||||
child: Row(
|
onTap: () => _showSortSheet(context, photos),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Text(sortFieldLabel(photos.sortField)),
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close_rounded, size: 20),
|
|
||||||
tooltip: 'Cancel selection',
|
|
||||||
onPressed: _clearSelection,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
'${selectedItems.length} selected',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
const SizedBox(width: 8),
|
||||||
child: SingleChildScrollView(
|
NooChip(
|
||||||
controller: _selectionActionsScrollController,
|
icon: LucideIcons.filter,
|
||||||
scrollDirection: Axis.horizontal,
|
trailing: NooChipTrailing.menu,
|
||||||
// Left-aligned (not anchored to the trailing edge) so the
|
selected: filtersActive,
|
||||||
// first action's left edge sits at a fixed spot - lining up
|
onTap: () => _showFilterSheet(context, photos, files),
|
||||||
// with the controls row's own first icon directly below it -
|
child: const Text('Filters'),
|
||||||
// regardless of how many actions there are.
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
for (final action in _buildSelectionActions(
|
|
||||||
pick,
|
|
||||||
selectedItems,
|
|
||||||
))
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(action.icon, size: 20),
|
|
||||||
tooltip: action.label,
|
|
||||||
onPressed: action.onTap,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPhotoTile(BuildContext context, NextcloudItem photo) {
|
void _showSortSheet(BuildContext context, PhotosController photos) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
showNooSheet(
|
||||||
|
context,
|
||||||
|
children: [
|
||||||
|
// `showNooSheet`'s `children` are built once, up front - a bare
|
||||||
|
// checkmark here would freeze at whatever it was when the sheet
|
||||||
|
// opened, since tapping a row calls `photos.set...`/notifies the
|
||||||
|
// controller, not this already-built widget tree. `ListenableBuilder`
|
||||||
|
// re-runs its `builder` on every notification instead, so the
|
||||||
|
// selection updates live.
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: photos,
|
||||||
|
builder: (context, _) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
NooSegmentedControl<bool>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
value: photos.sortAscending,
|
||||||
|
onChanged: (asc) {
|
||||||
|
if (asc != photos.sortAscending) photos.toggleSortOrder();
|
||||||
|
},
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: true,
|
||||||
|
icon: LucideIcons.arrowUp,
|
||||||
|
label: 'Ascending',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: false,
|
||||||
|
icon: LucideIcons.arrowDown,
|
||||||
|
label: 'Descending',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
NooGroupedList(
|
||||||
|
children: [
|
||||||
|
for (final field in FileSortField.values)
|
||||||
|
NooSettingsRow(
|
||||||
|
label: Text(sortFieldLabel(field)),
|
||||||
|
trailing: field == photos.sortField
|
||||||
|
? Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 18,
|
||||||
|
color: context.nooColors.accentText,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
photos.setSortField(field);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFilterSheet(
|
||||||
|
BuildContext context,
|
||||||
|
PhotosController photos,
|
||||||
|
FilesController files,
|
||||||
|
) {
|
||||||
|
showNooSheet(
|
||||||
|
context,
|
||||||
|
children: [
|
||||||
|
// `_typeFilter` lives on this State, not a ChangeNotifier, so
|
||||||
|
// `Listenable.merge([photos, files])` alone doesn't rebuild this
|
||||||
|
// sheet when it changes - it used to only visibly move once the
|
||||||
|
// sheet was closed and reopened. `StatefulBuilder` gives it a
|
||||||
|
// rebuild trigger of its own; see `_showSortSheet`'s comment for
|
||||||
|
// why the two controllers still need `ListenableBuilder`.
|
||||||
|
StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) => ListenableBuilder(
|
||||||
|
listenable: Listenable.merge([photos, files]),
|
||||||
|
builder: (context, _) => Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Same "icon always, label only when selected" pill as
|
||||||
|
// Files' own type filter - not a fully-labelled track, so
|
||||||
|
// the two screens' filter sheets look and behave the same.
|
||||||
|
NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
labelOnlySelected: true,
|
||||||
|
value: _typeFilter,
|
||||||
|
onChanged: (filter) => setState(() {
|
||||||
|
_typeFilter = filter;
|
||||||
|
setSheetState(() {});
|
||||||
|
}),
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: _PhotoTypeFilter.all,
|
||||||
|
icon: LucideIcons.layoutGrid,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: _PhotoTypeFilter.image,
|
||||||
|
icon: LucideIcons.image,
|
||||||
|
label: 'Photos',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: _PhotoTypeFilter.video,
|
||||||
|
icon: LucideIcons.film,
|
||||||
|
label: 'Videos',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
NooGroupedList(
|
||||||
|
children: [
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.heart,
|
||||||
|
label: const Text('Favorites only'),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: photos.showFavoritesOnly,
|
||||||
|
onChanged: (_) => photos.toggleFavoritesFilter(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
label: const Text('Show hidden files'),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: photos.showHidden,
|
||||||
|
onChanged: (_) => photos.toggleShowHidden(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
label: const Text('External storage'),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: files.storageScope == StorageScope.external,
|
||||||
|
onChanged: (external) => files.setStorageScope(
|
||||||
|
external ? StorageScope.external : StorageScope.cloud,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the controls row's own sticky slot while selecting - a close
|
||||||
|
/// button, the "N selected" count, and the bulk actions. Mirrors
|
||||||
|
/// `FilesView`'s identical selection bar.
|
||||||
|
Widget _buildSelectionBar(
|
||||||
|
BuildContext context,
|
||||||
|
PickController pick,
|
||||||
|
List<NextcloudItem> selectedItems,
|
||||||
|
) {
|
||||||
|
return NooSelectionBar(
|
||||||
|
count: selectedItems.length,
|
||||||
|
actions: _buildSelectionActions(pick, selectedItems),
|
||||||
|
onClose: _clearSelection,
|
||||||
|
isDesktop: NooLayout.isDesktop(context),
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhotoTile(
|
||||||
|
BuildContext context,
|
||||||
|
NextcloudItem photo,
|
||||||
|
List<NextcloudItem> siblings,
|
||||||
|
) {
|
||||||
final pick = context.watch<PickController>();
|
final pick = context.watch<PickController>();
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final isSelected = _selectedIds.contains(photo.id);
|
final isSelected = _selectedIds.contains(photo.id);
|
||||||
|
final isVideo = photo.type == NextcloudItemType.video;
|
||||||
|
|
||||||
// No border radius on the tile itself — the outer ClipRRect below is
|
return NooPhotoTile(
|
||||||
// the only place that rounds it, so a swipe doesn't reveal a stray
|
selected: isSelected,
|
||||||
// floating rounded corner mid-drag (see the same fix in files_view).
|
selectionMode: _isSelecting,
|
||||||
final tile = GestureDetector(
|
// No real duration source (no video-metadata extraction anywhere in
|
||||||
|
// the app) - an empty string still marks it as a video and shows the
|
||||||
|
// icon-only badge (DESIGN_SYSTEM.md: "else icon-only").
|
||||||
|
videoDuration: isVideo ? '' : null,
|
||||||
|
placeholderColor: NooPhotoTile.paletteColor(photo.id.hashCode),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (pick.isPicking) {
|
if (pick.isPicking) {
|
||||||
_handlePickTap(context, pick, photo);
|
_handlePickTap(context, pick, photo);
|
||||||
} else if (_isSelecting) {
|
} else if (_isSelecting) {
|
||||||
_toggleSelection(photo);
|
_toggleSelection(photo);
|
||||||
} else {
|
} else {
|
||||||
_openLightbox(context, photo);
|
_openLightbox(context, photo, siblings);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
|
onLongPress: pick.isPicking && !pick.pickRequest!.allowMultiple
|
||||||
? null
|
? null
|
||||||
: () => _toggleSelection(photo),
|
: () => _toggleSelection(photo),
|
||||||
child: ColoredBox(
|
child: photo.previewUrl != null
|
||||||
color: colorScheme.surfaceContainerHigh,
|
? LayoutBuilder(
|
||||||
child: SelectableThumbnail(
|
builder: (context, constraints) {
|
||||||
isSelected: isSelected,
|
// Decode at the tile's actual rendered size rather than the
|
||||||
checkmarkSize: 32,
|
// full 500x500 preview the server returns - cheaper to
|
||||||
child: Stack(
|
// decode/cache while scrolling a grid.
|
||||||
fit: StackFit.expand,
|
final cachePixels =
|
||||||
children: [
|
(constraints.maxWidth *
|
||||||
photo.previewUrl != null
|
MediaQuery.of(context).devicePixelRatio)
|
||||||
? LayoutBuilder(
|
.round();
|
||||||
builder: (context, constraints) {
|
return Image.network(
|
||||||
// Decode at the tile's actual rendered size rather
|
photo.previewUrl!,
|
||||||
// than the full 500x500 preview the server returns —
|
headers: session.service?.authHeaders,
|
||||||
// cheaper to decode/cache while scrolling a grid.
|
fit: BoxFit.cover,
|
||||||
final cachePixels =
|
cacheWidth: cachePixels,
|
||||||
(constraints.maxWidth *
|
cacheHeight: cachePixels,
|
||||||
MediaQuery.of(context).devicePixelRatio)
|
filterQuality: FilterQuality.low,
|
||||||
.round();
|
gaplessPlayback: true,
|
||||||
return Image.network(
|
errorBuilder: (ctx, err, stack) =>
|
||||||
photo.previewUrl!,
|
_buildFallbackTile(context, photo),
|
||||||
headers: session.service?.authHeaders,
|
);
|
||||||
fit: BoxFit.cover,
|
},
|
||||||
cacheWidth: cachePixels,
|
)
|
||||||
cacheHeight: cachePixels,
|
: _buildFallbackTile(context, photo),
|
||||||
filterQuality: FilterQuality.low,
|
|
||||||
gaplessPlayback: true,
|
|
||||||
errorBuilder: (ctx, err, stack) =>
|
|
||||||
_buildFallbackTile(context, photo),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: _buildFallbackTile(context, photo),
|
|
||||||
if (photo.type == NextcloudItemType.video)
|
|
||||||
Positioned(
|
|
||||||
bottom: 8,
|
|
||||||
right: 8,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.6),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.play_arrow_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (photo.isFavorite)
|
|
||||||
const Positioned(
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
child: Icon(
|
|
||||||
Icons.favorite_rounded,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return ClipRRect(borderRadius: BorderRadius.circular(16), child: tile);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
|
Widget _buildFallbackTile(BuildContext context, NextcloudItem photo) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colors = context.nooColors;
|
||||||
return Container(
|
final kind = NooFileKind.from(name: photo.name, mimeType: photo.mimeType);
|
||||||
color: colorScheme.secondaryContainer,
|
return ColoredBox(
|
||||||
|
color: kind.background(colors),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(kind.icon, color: kind.foreground(colors), size: 32),
|
||||||
photo.type == NextcloudItemType.video
|
|
||||||
? Icons.movie_rounded
|
|
||||||
: Icons.image_rounded,
|
|
||||||
color: colorScheme.onSecondaryContainer,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openLightbox(BuildContext context, NextcloudItem photo) {
|
void _openLightbox(
|
||||||
final photos = context.read<PhotosController>().items;
|
BuildContext context,
|
||||||
|
NextcloudItem photo,
|
||||||
|
List<NextcloudItem> siblings,
|
||||||
|
) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
FileViewerScreen.route(item: photo, siblings: photos),
|
FileViewerScreen.route(item: photo, siblings: siblings),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,37 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/recent_controller.dart';
|
import '../providers/recent_controller.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
|
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||||
|
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
|
import '../widgets/share_sheet.dart';
|
||||||
|
import '../widgets/tabs/tab_day_groups.dart';
|
||||||
|
import '../widgets/tabs/tab_location.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
|
|
||||||
IconData _recentItemIcon(NextcloudItemType type) {
|
|
||||||
switch (type) {
|
|
||||||
case NextcloudItemType.folder:
|
|
||||||
return Icons.folder_rounded;
|
|
||||||
case NextcloudItemType.image:
|
|
||||||
return Icons.image_rounded;
|
|
||||||
case NextcloudItemType.video:
|
|
||||||
return Icons.movie_rounded;
|
|
||||||
case NextcloudItemType.audio:
|
|
||||||
return Icons.audiotrack_rounded;
|
|
||||||
case NextcloudItemType.document:
|
|
||||||
return Icons.description_rounded;
|
|
||||||
case NextcloudItemType.archive:
|
|
||||||
return Icons.folder_zip_rounded;
|
|
||||||
case NextcloudItemType.file:
|
|
||||||
return Icons.insert_drive_file_rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _recentItemIconColor(BuildContext context, NextcloudItemType type) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
switch (type) {
|
|
||||||
case NextcloudItemType.folder:
|
|
||||||
return colorScheme.primary;
|
|
||||||
case NextcloudItemType.image:
|
|
||||||
return Colors.amber.shade700;
|
|
||||||
case NextcloudItemType.video:
|
|
||||||
return Colors.deepOrange.shade600;
|
|
||||||
case NextcloudItemType.audio:
|
|
||||||
return Colors.purple.shade600;
|
|
||||||
case NextcloudItemType.document:
|
|
||||||
return Colors.blue.shade700;
|
|
||||||
case NextcloudItemType.archive:
|
|
||||||
return Colors.teal.shade700;
|
|
||||||
case NextcloudItemType.file:
|
|
||||||
return colorScheme.outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recently modified files across the whole account (not folders), newest
|
/// Recently modified files across the whole account (not folders), newest
|
||||||
/// first — see [RecentController.fetchAll].
|
/// first - see [RecentController.fetchAll]. Grouped into Today/Yesterday/
|
||||||
|
/// This week/Earlier per `DESIGN_SYSTEM.md` §4; meta is "Modified {time} ·
|
||||||
|
/// {location}" since the server only ever reports a modification, not a
|
||||||
|
/// distinct action per entry.
|
||||||
class RecentView extends StatefulWidget {
|
class RecentView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const RecentView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const RecentView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RecentView> createState() => _RecentViewState();
|
State<RecentView> createState() => _RecentViewState();
|
||||||
@@ -63,8 +42,7 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final recent = context.watch<RecentController>();
|
final recent = context.watch<RecentController>();
|
||||||
|
|
||||||
if (!_requested) {
|
if (!_requested) {
|
||||||
@@ -73,174 +51,180 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final items = recent.items;
|
final items = recent.items;
|
||||||
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
final groups = groupByRecentBucket<NextcloudItem>(
|
||||||
|
items,
|
||||||
|
(item) => item.lastModified,
|
||||||
|
);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||||
if (recent.isLoading && items.isEmpty)
|
if (recent.isLoading && items.isEmpty)
|
||||||
const SliverFillRemaining(
|
tabLoadingSliver
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
)
|
|
||||||
else if (recent.errorMessage != null)
|
else if (recent.errorMessage != null)
|
||||||
SliverFillRemaining(
|
tabErrorSliver(
|
||||||
hasScrollBody: false,
|
context,
|
||||||
child: Center(
|
title: 'Could not load recent files',
|
||||||
child: Padding(
|
message: recent.errorMessage!,
|
||||||
padding: const EdgeInsets.all(24),
|
onRetry: recent.fetchAll,
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.error_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Could not load recent files',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
recent.errorMessage!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: recent.fetchAll,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else if (items.isEmpty)
|
else if (items.isEmpty)
|
||||||
SliverFillRemaining(
|
tabEmptySliver(
|
||||||
hasScrollBody: false,
|
context,
|
||||||
child: Center(
|
icon: LucideIcons.clock,
|
||||||
child: Column(
|
message: 'No recent files',
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.history_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'No recent files',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
else if (isDesktop)
|
||||||
|
_buildDesktopTable(context, groups)
|
||||||
else
|
else
|
||||||
SliverPadding(
|
_buildMobileGroups(context, groups),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
...tabBottomInsetSlivers(context),
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
return _buildTile(context, item);
|
|
||||||
}, childCount: items.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return SyncedHeaderScaffold(
|
return ColoredBox(
|
||||||
scrollController: widget.scrollController,
|
color: colors.bg,
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
child: RefreshIndicator(
|
||||||
onRefresh: recent.fetchAll,
|
color: colors.accent,
|
||||||
contentSlivers: contentSlivers,
|
backgroundColor: colors.surface,
|
||||||
);
|
onRefresh: recent.fetchAll,
|
||||||
}
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top
|
||||||
Widget _buildTile(BuildContext context, NextcloudItem item) {
|
// bar above it fully collapses.
|
||||||
final theme = Theme.of(context);
|
child: SafeArea(
|
||||||
final colorScheme = theme.colorScheme;
|
top: true,
|
||||||
final iconColor = _recentItemIconColor(context, item.type);
|
bottom: false,
|
||||||
final folderPath = item.path.substring(
|
child: CustomScrollView(
|
||||||
0,
|
controller: widget.scrollController,
|
||||||
item.path.length - item.name.length,
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
);
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
return Padding(
|
slivers: contentSlivers,
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Material(
|
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
onTap: () => _openFile(context, item),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_recentItemIcon(item.type),
|
|
||||||
color: iconColor,
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
folderPath.isEmpty ? '/' : folderPath,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
DateFormat.yMMMd().add_jm().format(item.lastModified),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMobileGroups(
|
||||||
|
BuildContext context,
|
||||||
|
List<TabDayGroup<NextcloudItem>> groups,
|
||||||
|
) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final group = groups[index];
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: index == groups.length - 1 ? 0 : 18,
|
||||||
|
),
|
||||||
|
child: NooGroupedList(
|
||||||
|
label: group.label,
|
||||||
|
children: [
|
||||||
|
for (final item in group.items) _buildRow(context, item),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, childCount: groups.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopTable(
|
||||||
|
BuildContext context,
|
||||||
|
List<TabDayGroup<NextcloudItem>> groups,
|
||||||
|
) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.xl,
|
||||||
|
vertical: NooSpace.xs,
|
||||||
|
),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final group = groups[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 18),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TabGroupLabel(group.label),
|
||||||
|
const NooFileTableHeader(
|
||||||
|
col2Label: 'Modified',
|
||||||
|
col3Label: 'Location',
|
||||||
|
),
|
||||||
|
for (final item in group.items) _buildDesktopRow(context, item),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, childCount: groups.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRow(BuildContext context, NextcloudItem item) {
|
||||||
|
return NooFileRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
),
|
||||||
|
name: item.name,
|
||||||
|
meta: 'Modified ${_metaTime(item.lastModified)} · ${_location(item)}',
|
||||||
|
favorite: item.isFavorite,
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
onTap: () => _openFile(context, item),
|
||||||
|
onMore: () => _showItemSheet(context, item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
|
||||||
|
return NooFileTableRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
),
|
||||||
|
name: item.name,
|
||||||
|
col2: _metaTime(item.lastModified),
|
||||||
|
col3: _location(item),
|
||||||
|
favorite: item.isFavorite,
|
||||||
|
onTap: () => _openFile(context, item),
|
||||||
|
onMore: () => _showItemSheet(context, item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showItemSheet(BuildContext context, NextcloudItem item) {
|
||||||
|
showNooSheet(
|
||||||
|
context,
|
||||||
|
children: [
|
||||||
|
NooGroupedList(
|
||||||
|
children: [
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.info,
|
||||||
|
label: const Text('Details'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
DetailsSheet.show(context, item);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.share2,
|
||||||
|
label: const Text('Share'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ShareSheet.show(context, item);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preserves the original screen's exact tap behavior: always opens the
|
||||||
|
/// media viewer (the server's recent-files search only ever returns
|
||||||
|
/// files, never folders, so there's no folder-navigation case to handle).
|
||||||
void _openFile(BuildContext context, NextcloudItem item) {
|
void _openFile(BuildContext context, NextcloudItem item) {
|
||||||
final recent = context.read<RecentController>();
|
final recent = context.read<RecentController>();
|
||||||
final siblings = recent.items.where((i) => i.isMedia).toList();
|
final siblings = recent.items.where((i) => i.isMedia).toList();
|
||||||
@@ -250,3 +234,14 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _metaTime(DateTime dt) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
|
||||||
|
return DateFormat.jm().format(dt);
|
||||||
|
}
|
||||||
|
if (dt.year == now.year) return DateFormat.MMMd().format(dt);
|
||||||
|
return DateFormat.yMMMd().format(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _location(NextcloudItem item) => tabLocationLabel(item.path, item.name);
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/files_controller.dart';
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/settings_controller.dart';
|
import '../providers/settings_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
|
import '../widgets/noo/core/noo_search_field.dart';
|
||||||
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
|
|
||||||
|
/// Full-text file search, pushed from the shell's search entry point
|
||||||
|
/// (`openSearch` in `widgets/shell/shell_common.dart`). Debounces input,
|
||||||
|
/// then lists matches as file rows - tapping one jumps to the result on the
|
||||||
|
/// Files tab (see [_openResult]). Per DESIGN_SYSTEM.md §4 "Search".
|
||||||
class SearchView extends StatefulWidget {
|
class SearchView extends StatefulWidget {
|
||||||
const SearchView({super.key});
|
const SearchView({super.key});
|
||||||
|
|
||||||
@@ -74,25 +89,6 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _iconFor(NextcloudItemType type) {
|
|
||||||
switch (type) {
|
|
||||||
case NextcloudItemType.folder:
|
|
||||||
return Icons.folder_rounded;
|
|
||||||
case NextcloudItemType.image:
|
|
||||||
return Icons.image_rounded;
|
|
||||||
case NextcloudItemType.video:
|
|
||||||
return Icons.movie_rounded;
|
|
||||||
case NextcloudItemType.audio:
|
|
||||||
return Icons.audiotrack_rounded;
|
|
||||||
case NextcloudItemType.document:
|
|
||||||
return Icons.description_rounded;
|
|
||||||
case NextcloudItemType.archive:
|
|
||||||
return Icons.folder_zip_rounded;
|
|
||||||
case NextcloudItemType.file:
|
|
||||||
return Icons.insert_drive_file_rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Navigates the Files tab to [item] (its own folder if it's a folder,
|
/// Navigates the Files tab to [item] (its own folder if it's a folder,
|
||||||
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
|
/// its parent folder otherwise - rebuilding the whole breadcrumb trail
|
||||||
/// from root rather than assuming it's under wherever the user was
|
/// from root rather than assuming it's under wherever the user was
|
||||||
@@ -113,98 +109,249 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _clear() {
|
||||||
|
_controller.clear();
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
_onChanged('');
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
backgroundColor: colors.bg,
|
||||||
child: Column(
|
appBar: desktop
|
||||||
children: [
|
? NooToolbar(
|
||||||
Padding(
|
titleWidget: _buildField(onSurface: true),
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
actions: [
|
||||||
child: SearchBar(
|
IconTheme.merge(
|
||||||
controller: _controller,
|
data: IconThemeData(color: colors.fg1, size: 24),
|
||||||
focusNode: _focusNode,
|
child: NooTopBarButton(
|
||||||
autoFocus: true,
|
icon: LucideIcons.x,
|
||||||
hintText: 'Search your files...',
|
tooltip: 'Close',
|
||||||
elevation: const WidgetStatePropertyAll(0),
|
onTap: () => Navigator.pop(context),
|
||||||
onChanged: _onChanged,
|
),
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
),
|
||||||
trailing: [
|
],
|
||||||
if (_controller.text.isNotEmpty)
|
)
|
||||||
IconButton(
|
: null,
|
||||||
icon: const Icon(Icons.close_rounded),
|
body: SafeArea(
|
||||||
onPressed: () {
|
top: !desktop,
|
||||||
_controller.clear();
|
child: desktop
|
||||||
_onChanged('');
|
? _buildBody(context, desktop)
|
||||||
},
|
: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.xs,
|
||||||
|
NooSpace.xs,
|
||||||
|
NooSpace.md,
|
||||||
|
NooSpace.xs,
|
||||||
),
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconTheme.merge(
|
||||||
|
data: IconThemeData(color: colors.fg1, size: 24),
|
||||||
|
child: NooTopBarButton(
|
||||||
|
icon: LucideIcons.arrowLeft,
|
||||||
|
tooltip: 'Back',
|
||||||
|
onTap: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildField(onSurface: false)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildBody(context, desktop)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildField({required bool onSurface}) {
|
||||||
|
// The pill `NooSearchField`, not the inset-panel `NooTextField` - this
|
||||||
|
// is a search field (DESIGN_SYSTEM.md's "Search field" component,
|
||||||
|
// radius 999 outside the one iOS-inline exception), not a form input.
|
||||||
|
// `ios: false` matches the Android shape we're building against now;
|
||||||
|
// revisit once the iOS large-title/inline-field entry point is built.
|
||||||
|
return NooSearchField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
autofocus: true,
|
||||||
|
placeholder: 'Search files',
|
||||||
|
onChanged: _onChanged,
|
||||||
|
onSurface: onSurface,
|
||||||
|
ios: false,
|
||||||
|
trailing: _controller.text.isEmpty
|
||||||
|
? null
|
||||||
|
: _ClearFieldButton(onTap: _clear),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context, bool desktop) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
|
||||||
|
if (_isSearching) {
|
||||||
|
return Center(child: CircularProgressIndicator(color: colors.accent));
|
||||||
|
}
|
||||||
|
if (_error != null) {
|
||||||
|
return _buildMessage(
|
||||||
|
context,
|
||||||
|
icon: LucideIcons.circleAlert,
|
||||||
|
iconColor: colors.danger,
|
||||||
|
title: 'Couldn\'t search',
|
||||||
|
message: _error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!_hasSearched) {
|
||||||
|
return _buildMessage(
|
||||||
|
context,
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
iconColor: colors.fg3,
|
||||||
|
title: 'Search your files',
|
||||||
|
message: 'Find files and folders by name.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_results.isEmpty) {
|
||||||
|
return _buildMessage(
|
||||||
|
context,
|
||||||
|
icon: LucideIcons.searchX,
|
||||||
|
iconColor: colors.fg3,
|
||||||
|
title: 'No results found',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessage(
|
||||||
|
BuildContext context, {
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String title,
|
||||||
|
String? message,
|
||||||
|
}) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(NooSpace.xl),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 56, color: iconColor),
|
||||||
|
const SizedBox(height: NooSpace.sm),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: NooText.cardTitle.copyWith(color: colors.fg2),
|
||||||
),
|
),
|
||||||
Expanded(child: _buildBody(context, colorScheme, theme)),
|
if (message != null) ...[
|
||||||
|
const SizedBox(height: NooSpace.xxs),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(
|
Widget _buildMobileResults(BuildContext context) {
|
||||||
BuildContext context,
|
|
||||||
ColorScheme colorScheme,
|
|
||||||
ThemeData theme,
|
|
||||||
) {
|
|
||||||
if (_isSearching) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
if (_error != null) {
|
|
||||||
return Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Text(
|
|
||||||
_error!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(color: colorScheme.error),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!_hasSearched) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
if (_results.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
'No results found',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xs,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xl,
|
||||||
|
),
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
itemCount: _results.length,
|
itemCount: _results.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _results[index];
|
final item = _results[index];
|
||||||
return ListTile(
|
return Padding(
|
||||||
leading: Icon(_iconFor(item.type), color: colorScheme.primary),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
child: ClipRRect(
|
||||||
subtitle: Text(
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
item.isFolder
|
child: NooFileRow(
|
||||||
? item.path
|
kind: NooFileKind.from(
|
||||||
: DateFormat.yMMMd().format(item.lastModified),
|
name: item.name,
|
||||||
maxLines: 1,
|
mimeType: item.mimeType,
|
||||||
overflow: TextOverflow.ellipsis,
|
isDirectory: item.isFolder,
|
||||||
|
),
|
||||||
|
name: item.name,
|
||||||
|
meta: _mobileMeta(item),
|
||||||
|
favorite: item.isFavorite,
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
onTap: () => _openResult(item),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onTap: () => _openResult(item),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopResults(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.xl,
|
||||||
|
vertical: NooSpace.sm,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
|
||||||
|
for (final item in _results)
|
||||||
|
NooFileTableRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
),
|
||||||
|
name: item.name,
|
||||||
|
col2: DateFormat.yMMMd().format(item.lastModified),
|
||||||
|
col3: item.isFolder ? item.path : p.dirname(item.path),
|
||||||
|
favorite: item.isFavorite,
|
||||||
|
onTap: () => _openResult(item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors the pre-rework screen's exact copy: a folder result shows its
|
||||||
|
/// own full path (there's no separate "location" field to fall back to),
|
||||||
|
/// a file result shows when it was last modified.
|
||||||
|
String _mobileMeta(NextcloudItem item) =>
|
||||||
|
item.isFolder ? item.path : DateFormat.yMMMd().format(item.lastModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small round "clear" button for [NooTextField.trailing] - clears the
|
||||||
|
/// query and puts focus back in the field, same as the old `SearchBar`'s
|
||||||
|
/// trailing close icon.
|
||||||
|
class _ClearFieldButton extends StatelessWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ClearFieldButton({required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return SizedBox.square(
|
||||||
|
dimension: 32,
|
||||||
|
child: Material(
|
||||||
|
type: MaterialType.transparency,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Icon(LucideIcons.x, size: 16, color: colors.fg3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
@@ -9,24 +8,32 @@ import '../providers/session_controller.dart';
|
|||||||
import '../providers/settings_controller.dart';
|
import '../providers/settings_controller.dart';
|
||||||
import '../services/share_intent_service.dart';
|
import '../services/share_intent_service.dart';
|
||||||
import '../services/upload_service.dart';
|
import '../services/upload_service.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/breadcrumbs.dart';
|
import '../widgets/breadcrumbs.dart';
|
||||||
import '../widgets/item_icon.dart';
|
import '../widgets/files_controls_row.dart';
|
||||||
import '../widgets/marquee_title.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
import '../widgets/segmented_icon_toggle.dart';
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
import '../widgets/sort_menu_button.dart';
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/noo/media/noo_grid_card.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/shell/shell_common.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
|
|
||||||
/// Shown when another app shares one or more files to Noo (Android's
|
/// Shown when another app shares one or more files to Noo (Android's
|
||||||
/// "Share to..." sheet). Lets the user browse to a destination folder,
|
/// "Share to..." sheet). Lets the user browse to a destination folder,
|
||||||
/// mirroring the Files tab's own controls/filters/listing so this feels
|
/// reusing `FilesController`'s shared folder-browsing state so this feels
|
||||||
/// like the same browser rather than a stripped-down picker, then hands the
|
/// like the same browser Files itself uses, then hands the actual
|
||||||
/// actual prepare+upload off to [UploadService] - a real Android foreground
|
/// prepare+upload off to [UploadService] - a real Android foreground
|
||||||
/// service (see `ShareUploadService.kt`'s doc comment), not something this
|
/// service (see `ShareUploadService.kt`'s doc comment), not something this
|
||||||
/// screen or even the app needs to stay open for. [files] only ever carry
|
/// screen or even the app needs to stay open for. [files] only ever carry
|
||||||
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
|
/// cheap Uri metadata (see [ShareIntentService]'s doc comment); that
|
||||||
/// service is the only thing that ever reads their actual bytes.
|
/// service is the only thing that ever reads their actual bytes. Pushed
|
||||||
|
/// via `Navigator` from outside `MainShellView` (a cold share-intent
|
||||||
|
/// launch, or Files' own "+" -> "Upload file"), so - unlike a tab - it
|
||||||
|
/// builds its own complete top chrome rather than relying on the shell's.
|
||||||
class ShareUploadView extends StatefulWidget {
|
class ShareUploadView extends StatefulWidget {
|
||||||
final List<SharedFileRef> files;
|
final List<SharedFileRef> files;
|
||||||
|
|
||||||
@@ -45,7 +52,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
// Shared files have no relationship to wherever the user was last
|
// Shared files have no relationship to wherever the user was last
|
||||||
// browsing, so start the destination picker fresh at the root.
|
// browsing, so start the destination picker fresh at the root.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) context.read<FilesController>().navigateToAbsoluteFolder('/');
|
if (mounted) {
|
||||||
|
context.read<FilesController>().navigateToAbsoluteFolder('/');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,243 +95,67 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildControlsRow(FilesController files) {
|
// Only folders are valid upload destinations, so the listing - unlike
|
||||||
return SizedBox(
|
// Files' own - never shows plain files at all.
|
||||||
height: 44,
|
Widget _buildRow(
|
||||||
child: SingleChildScrollView(
|
BuildContext context,
|
||||||
scrollDirection: Axis.horizontal,
|
NextcloudItem item,
|
||||||
child: Row(
|
int index,
|
||||||
children: [
|
int count,
|
||||||
IconButton(
|
) {
|
||||||
icon: Icon(
|
final colors = context.nooColors;
|
||||||
files.filesSortAscending
|
final files = context.read<FilesController>();
|
||||||
? Icons.arrow_upward_rounded
|
final row = NooFileRow(
|
||||||
: Icons.arrow_downward_rounded,
|
kind: NooFileKind.folder,
|
||||||
size: 20,
|
name: item.name,
|
||||||
),
|
meta: 'Folder',
|
||||||
visualDensity: VisualDensity.compact,
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
tooltip: files.filesSortAscending ? 'Ascending' : 'Descending',
|
onTap: () => files.navigateToFolder(item.path),
|
||||||
onPressed: files.toggleFilesSortOrder,
|
);
|
||||||
),
|
|
||||||
SizedBox(
|
final isFirst = index == 0;
|
||||||
width: 130,
|
final isLast = index == count - 1;
|
||||||
child: SortMenuButton(
|
return Column(
|
||||||
field: files.filesSortField,
|
children: [
|
||||||
onChanged: files.setFilesSortField,
|
ClipRRect(
|
||||||
),
|
borderRadius: BorderRadius.vertical(
|
||||||
),
|
top: isFirst ? const Radius.circular(NooRadii.card) : Radius.zero,
|
||||||
ToggleIconButton(
|
bottom: isLast ? const Radius.circular(NooRadii.card) : Radius.zero,
|
||||||
icon: files.showHiddenFiles
|
),
|
||||||
? Icons.visibility_rounded
|
child: row,
|
||||||
: Icons.visibility_off_rounded,
|
|
||||||
isSelected: files.showHiddenFiles,
|
|
||||||
onTap: () => files.toggleShowHiddenFiles(),
|
|
||||||
tooltip: 'Show hidden files',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.circles_rounded,
|
|
||||||
isSelected: files.storageScope == StorageScope.cloud,
|
|
||||||
onTap: () => files.setStorageScope(StorageScope.cloud),
|
|
||||||
tooltip: 'Cloud storage',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Symbols.hard_drive_rounded,
|
|
||||||
isSelected: files.storageScope == StorageScope.external,
|
|
||||||
onTap: () => files.setStorageScope(StorageScope.external),
|
|
||||||
tooltip: 'External storage',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.select_all_rounded,
|
|
||||||
isSelected: files.filesTypeFilter == FilesTypeFilter.all,
|
|
||||||
onTap: () => files.setFilesTypeFilter(FilesTypeFilter.all),
|
|
||||||
tooltip: 'Files & folders',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.insert_drive_file_outlined,
|
|
||||||
isSelected:
|
|
||||||
files.filesTypeFilter == FilesTypeFilter.filesOnly,
|
|
||||||
onTap: () =>
|
|
||||||
files.setFilesTypeFilter(FilesTypeFilter.filesOnly),
|
|
||||||
tooltip: 'Files only',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.folder_outlined,
|
|
||||||
isSelected:
|
|
||||||
files.filesTypeFilter == FilesTypeFilter.foldersOnly,
|
|
||||||
onTap: () =>
|
|
||||||
files.setFilesTypeFilter(FilesTypeFilter.foldersOnly),
|
|
||||||
tooltip: 'Folders only',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
SegmentedIconGroup(
|
|
||||||
children: [
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.view_list_rounded,
|
|
||||||
isSelected: !files.isGridView,
|
|
||||||
onTap: () => files.setGridView(false),
|
|
||||||
tooltip: 'List view',
|
|
||||||
),
|
|
||||||
ToggleIconButton(
|
|
||||||
icon: Icons.grid_view_rounded,
|
|
||||||
isSelected: files.isGridView,
|
|
||||||
onTap: () => files.setGridView(true),
|
|
||||||
tooltip: 'Grid view',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (!isLast) Container(height: 1, color: colors.line),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only folders are valid upload destinations - files still show (so the
|
Widget _buildDesktopRow(BuildContext context, NextcloudItem item) {
|
||||||
// listing matches what the Files tab itself would show for this folder)
|
|
||||||
// but are visually dimmed and inert rather than hidden outright.
|
|
||||||
Widget _buildListTile(BuildContext context, NextcloudItem item) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final files = context.read<FilesController>();
|
final files = context.read<FilesController>();
|
||||||
final session = context.watch<SessionController>();
|
return NooFileTableRow(
|
||||||
final isFolder = item.isFolder;
|
kind: NooFileKind.folder,
|
||||||
|
name: item.name,
|
||||||
return Padding(
|
onTap: () => files.navigateToFolder(item.path),
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Opacity(
|
|
||||||
opacity: isFolder ? 1 : 0.5,
|
|
||||||
child: Material(
|
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ItemThumbnail(
|
|
||||||
item: item,
|
|
||||||
service: session.service,
|
|
||||||
size: 44,
|
|
||||||
borderRadius: 12,
|
|
||||||
iconSize: 22,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
isFolder
|
|
||||||
? 'Folder'
|
|
||||||
: '${formatBytes(item.size)} • ${DateFormat.yMMMd().format(item.lastModified)}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isFolder) const Icon(Icons.chevron_right_rounded),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
|
Widget _buildGridCard(BuildContext context, NextcloudItem item) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final files = context.read<FilesController>();
|
final files = context.read<FilesController>();
|
||||||
final isFolder = item.isFolder;
|
return NooGridCard(
|
||||||
final iconColor = getIconColor(context, item.type);
|
name: item.name,
|
||||||
|
meta: 'Folder',
|
||||||
return Opacity(
|
placeholderColor: colors.accentSoft,
|
||||||
opacity: isFolder ? 1 : 0.5,
|
icon: LucideIcons.folder,
|
||||||
child: Material(
|
iconColor: colors.accentText,
|
||||||
color: colorScheme.surfaceContainerLow,
|
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
|
||||||
borderRadius: BorderRadius.circular(20),
|
onTap: () => files.navigateToFolder(item.path),
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: isFolder ? () => files.navigateToFolder(item.path) : null,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
getItemIcon(item.type),
|
|
||||||
color: iconColor,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
isFolder ? 'Folder' : formatBytes(item.size),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
final desktop = NooLayout.isDesktop(context);
|
||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final hasBreadcrumbs = files.pathStack.length > 1;
|
final hasBreadcrumbs = files.pathStack.length > 1;
|
||||||
@@ -330,171 +163,168 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
final currentLabel = currentPath == '/'
|
final currentLabel = currentPath == '/'
|
||||||
? 'Home'
|
? 'Home'
|
||||||
: currentPath.split('/').where((s) => s.isNotEmpty).last;
|
: currentPath.split('/').where((s) => s.isNotEmpty).last;
|
||||||
final items = files.items;
|
final folders = files.items.where((item) => item.isFolder).toList();
|
||||||
|
final gutter = NooLayout.gutter(context);
|
||||||
|
|
||||||
// Mirrors FilesView's own controls-row + breadcrumbs sticky header
|
final slivers = <Widget>[
|
||||||
// exactly (padding, heights) so this reads as the same browser, just
|
SliverToBoxAdapter(
|
||||||
// reached from a share intent instead of the Files tab.
|
child: Padding(
|
||||||
final controlsColumn = Padding(
|
padding: EdgeInsets.fromLTRB(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
gutter,
|
||||||
child: Column(
|
NooSpace.md,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
gutter,
|
||||||
children: [
|
NooSpace.sm,
|
||||||
_buildControlsRow(files),
|
),
|
||||||
if (hasBreadcrumbs) ...[
|
child: Column(
|
||||||
const SizedBox(height: 10),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
SizedBox(
|
children: [
|
||||||
height: 32,
|
SizedBox(
|
||||||
child: Breadcrumbs(
|
height: 44,
|
||||||
pathStack: files.pathStack,
|
child: FilesControlsRow(folderPath: currentPath),
|
||||||
onTap: (index) => files.navigateToPathIndex(index),
|
|
||||||
),
|
),
|
||||||
),
|
if (hasBreadcrumbs) ...[
|
||||||
],
|
const SizedBox(height: 10),
|
||||||
],
|
SizedBox(
|
||||||
),
|
height: 32,
|
||||||
);
|
child: Breadcrumbs(
|
||||||
|
pathStack: files.pathStack,
|
||||||
final contentSlivers = <Widget>[
|
onTap: (index) => files.navigateToPathIndex(index),
|
||||||
SliverPersistentHeader(
|
),
|
||||||
pinned: true,
|
),
|
||||||
delegate: StickyHeaderDelegate(
|
],
|
||||||
height: hasBreadcrumbs ? 114 : 72,
|
],
|
||||||
child: controlsColumn,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (files.isLoading)
|
if (files.isLoading)
|
||||||
const SliverFillRemaining(
|
tabLoadingSliver
|
||||||
hasScrollBody: false,
|
else if (folders.isEmpty)
|
||||||
child: Center(child: CircularProgressIndicator()),
|
tabEmptySliver(
|
||||||
)
|
context,
|
||||||
else if (items.isEmpty)
|
icon: LucideIcons.folder,
|
||||||
SliverFillRemaining(
|
message: 'No folders here',
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'Folder is empty',
|
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else if (files.isGridView)
|
else if (files.isGridView)
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: desktop ? 5 : 2,
|
||||||
childAspectRatio: 1.1,
|
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: 12,
|
crossAxisSpacing: desktop ? 16 : 10,
|
||||||
mainAxisSpacing: 12,
|
mainAxisSpacing: desktop ? 16 : 10,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildGridCard(context, folders[index]),
|
||||||
|
childCount: folders.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (desktop)
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) => _buildDesktopRow(context, folders[index]),
|
||||||
|
childCount: folders.length,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
return _buildGridCard(context, items[index]);
|
|
||||||
}, childCount: items.length),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate(
|
||||||
return _buildListTile(context, items[index]);
|
(context, index) =>
|
||||||
}, childCount: items.length),
|
_buildRow(context, folders[index], index, folders.length),
|
||||||
|
childCount: folders.length,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// So the last row isn't hidden behind the bottom "Upload to..." bar.
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.xl)),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final title = 'Upload to...';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SyncedHeaderScaffold(
|
backgroundColor: colors.bg,
|
||||||
scrollController: _scrollController,
|
appBar: desktop
|
||||||
// Same trailing actions as every other tab except "More tabs" -
|
? NooToolbar(
|
||||||
// there's nowhere useful for it to go while mid-upload (jumping to
|
title: title,
|
||||||
// Trash/Shares/etc. would abandon this destination picker), so the
|
actions: [
|
||||||
// top chrome is identical to every other tab minus that one entry.
|
NooButton(
|
||||||
// Backing out is still the system back gesture/button, same as any
|
variant: NooButtonVariant.secondary,
|
||||||
// other pushed screen.
|
size: NooButtonSize.toolbar,
|
||||||
actions: const [ProfileAvatarButton()],
|
onTap: () => Navigator.of(context).maybePop(),
|
||||||
contentSlivers: contentSlivers,
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: NooTopBar(
|
||||||
|
style: NooLayout.navStyle(context),
|
||||||
|
title: title,
|
||||||
|
leading: const NooTopBarBack(label: 'Cancel'),
|
||||||
|
// Same top-bar access as every tab (avatar -> Settings, swipe
|
||||||
|
// to switch accounts) - nothing else belongs here: jumping to
|
||||||
|
// another tab mid-upload would abandon this destination
|
||||||
|
// picker, so there's no "more tabs" entry.
|
||||||
|
actions: const [ShellAvatarButton()],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: RefreshIndicator(
|
||||||
|
color: colors.accent,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
onRefresh: files.refreshData,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-folder
|
||||||
|
// listing.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: slivers,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// A rounded-top, elevated bar "peeking" up from the bottom edge - the
|
// A flat surface bar with a top line, peeking up from the bottom edge -
|
||||||
// uploading-file summary (marqueed if it doesn't fit on one line)
|
// a centered item-count meta line sits directly above the destination
|
||||||
// sits directly above the destination button, both inside the one
|
// button (DESIGN_SYSTEM.md's "Upload / Move / Copy destination
|
||||||
// sheet, rather than the summary living up in the scrolling content
|
// picker" recipe: "a meta line ... above a full-width ... primary
|
||||||
// far away from the action it describes.
|
// CTA" - matches MoveCopyDestinationPicker's identical bar). A count,
|
||||||
bottomNavigationBar: Material(
|
// not a filename: this is the one-file case just as much as the
|
||||||
color: colorScheme.surfaceContainerHigh,
|
// many-files case, and a long filename has no good fixed-width
|
||||||
elevation: 8,
|
// treatment the way a short count always does.
|
||||||
shape: const RoundedRectangleBorder(
|
bottomNavigationBar: DecoratedBox(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surface,
|
||||||
|
border: Border(top: BorderSide(color: colors.line)),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.lg,
|
||||||
|
NooSpace.md,
|
||||||
|
NooSpace.lg,
|
||||||
|
NooSpace.md,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Same pill/chip look and exact same duller background
|
Text(
|
||||||
// color as the filter toggles' shared background
|
'Uploading ${widget.files.length} item${widget.files.length == 1 ? '' : 's'}',
|
||||||
// (SegmentedIconGroup's `surfaceContainerHigh`), so it reads
|
textAlign: TextAlign.center,
|
||||||
// as part of the same visual language rather than a new
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
// accent color. Sized to the text itself (like a real chip)
|
|
||||||
// up to the row's available width - MarqueeTitle needs a
|
|
||||||
// concrete (not just loose) width to know whether/how far
|
|
||||||
// to scroll, so this measures the text once up front rather
|
|
||||||
// than leaving the chip unconstrained.
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final uploadingText = widget.files.length == 1
|
|
||||||
? 'Uploading ${widget.files.first.name}'
|
|
||||||
: 'Uploading ${widget.files.length} files';
|
|
||||||
final chipTextStyle = theme.textTheme.titleSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
);
|
|
||||||
const horizontalPadding = 28.0;
|
|
||||||
final painter = TextPainter(
|
|
||||||
text: TextSpan(text: uploadingText, style: chipTextStyle),
|
|
||||||
maxLines: 1,
|
|
||||||
textDirection: Directionality.of(context),
|
|
||||||
)..layout(maxWidth: double.infinity);
|
|
||||||
final chipWidth = (painter.width + horizontalPadding).clamp(
|
|
||||||
0.0,
|
|
||||||
constraints.maxWidth,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
width: chipWidth,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 14,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 20,
|
|
||||||
child: MarqueeTitle(
|
|
||||||
text: uploadingText,
|
|
||||||
style: chipTextStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: NooSpace.sm),
|
||||||
SizedBox(
|
NooButton(
|
||||||
width: double.infinity,
|
variant: NooButtonVariant.primary,
|
||||||
child: FilledButton.icon(
|
size: NooButtonSize.cta,
|
||||||
onPressed: () => _uploadHere(session),
|
fullWidth: true,
|
||||||
icon: const Icon(Icons.upload_rounded),
|
icon: LucideIcons.upload,
|
||||||
label: Text('Upload to $currentLabel'),
|
onTap: () => _uploadHere(session),
|
||||||
),
|
child: Text('Upload to $currentLabel'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,53 +1,63 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/nextcloud_share.dart';
|
import '../models/nextcloud_share.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
import '../providers/shares_controller.dart';
|
import '../providers/shares_controller.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
|
import '../widgets/noo/noo_layout.dart';
|
||||||
|
import '../widgets/share_sheet.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
|
|
||||||
IconData _shareItemIcon(NextcloudItemType type) {
|
/// Which slice of [SharesController.shares] is on screen - `With you` maps
|
||||||
switch (type) {
|
/// straight onto the controller's own `sharedWithMe: true` fetch, but the
|
||||||
case NextcloudItemType.folder:
|
/// controller only ever exposes one boolean scope, so `By you` and `Links`
|
||||||
return Icons.folder_rounded;
|
/// both use its `sharedWithMe: false` fetch and are told apart client-side
|
||||||
case NextcloudItemType.image:
|
/// by [NextcloudShare.shareType] (see `_visibleShares`). `SharesController`
|
||||||
return Icons.image_rounded;
|
/// caches each of its two scopes after its first fetch, so switching
|
||||||
case NextcloudItemType.video:
|
/// between any of the three - including back to `With you` after leaving
|
||||||
return Icons.movie_rounded;
|
/// it - only re-fetches a scope that's never been loaded yet, not on every
|
||||||
case NextcloudItemType.audio:
|
/// single switch.
|
||||||
return Icons.audiotrack_rounded;
|
enum _ShareScope { withYou, byYou, links }
|
||||||
case NextcloudItemType.document:
|
|
||||||
return Icons.description_rounded;
|
|
||||||
case NextcloudItemType.archive:
|
|
||||||
return Icons.folder_zip_rounded;
|
|
||||||
case NextcloudItemType.file:
|
|
||||||
return Icons.insert_drive_file_rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _shareTypeIcon(ShareType type) {
|
IconData _shareTypeIcon(ShareType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case ShareType.user:
|
case ShareType.user:
|
||||||
return Icons.person_rounded;
|
return LucideIcons.user;
|
||||||
case ShareType.group:
|
case ShareType.group:
|
||||||
return Icons.groups_rounded;
|
return LucideIcons.users;
|
||||||
case ShareType.publicLink:
|
case ShareType.publicLink:
|
||||||
return Icons.link_rounded;
|
return LucideIcons.link;
|
||||||
case ShareType.email:
|
case ShareType.email:
|
||||||
return Icons.email_rounded;
|
return LucideIcons.mail;
|
||||||
case ShareType.federated:
|
case ShareType.federated:
|
||||||
return Icons.public_rounded;
|
return LucideIcons.globe;
|
||||||
case ShareType.other:
|
case ShareType.other:
|
||||||
return Icons.share_rounded;
|
return LucideIcons.share2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Can edit" when any write bit (update/create/delete) is set on the OCS
|
||||||
|
/// share permissions bitmask, "Can view" otherwise - kept to the two
|
||||||
|
/// broad buckets the row's one-line meta has room for.
|
||||||
|
String _permissionLabel(int permissions) {
|
||||||
|
const update = 2, create = 4, delete = 8;
|
||||||
|
final canWrite = permissions & (update | create | delete) != 0;
|
||||||
|
return canWrite ? 'Can edit' : 'Can view';
|
||||||
|
}
|
||||||
|
|
||||||
class SharesView extends StatefulWidget {
|
class SharesView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const SharesView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const SharesView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SharesView> createState() => _SharesViewState();
|
State<SharesView> createState() => _SharesViewState();
|
||||||
@@ -55,11 +65,13 @@ class SharesView extends StatefulWidget {
|
|||||||
|
|
||||||
class _SharesViewState extends State<SharesView> {
|
class _SharesViewState extends State<SharesView> {
|
||||||
bool _requested = false;
|
bool _requested = false;
|
||||||
|
// Mirrors `SharesController.sharedWithMe`'s own default (false, "shared
|
||||||
|
// by me") so the first fetch this triggers matches what's shown.
|
||||||
|
_ShareScope _scope = _ShareScope.byYou;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final sharesController = context.watch<SharesController>();
|
final sharesController = context.watch<SharesController>();
|
||||||
|
|
||||||
if (!_requested) {
|
if (!_requested) {
|
||||||
@@ -69,247 +81,216 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final shares = sharesController.shares;
|
final shares = _visibleShares(sharesController);
|
||||||
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
SliverToBoxAdapter(
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
child: Padding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
padding: EdgeInsets.fromLTRB(
|
||||||
child: SegmentedButton<bool>(
|
NooLayout.gutter(context),
|
||||||
segments: const [
|
NooSpace.md,
|
||||||
ButtonSegment(value: false, label: Text('Shared by me')),
|
NooLayout.gutter(context),
|
||||||
ButtonSegment(value: true, label: Text('Shared with me')),
|
0,
|
||||||
|
),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: NooSegmentedControl<_ShareScope>(
|
||||||
|
value: _scope,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(value: _ShareScope.withYou, label: 'With you'),
|
||||||
|
NooSegmentOption(value: _ShareScope.byYou, label: 'By you'),
|
||||||
|
NooSegmentOption(value: _ShareScope.links, label: 'Links'),
|
||||||
],
|
],
|
||||||
selected: {sharesController.sharedWithMe},
|
onChanged: (scope) => _selectScope(sharesController, scope),
|
||||||
onSelectionChanged: (set) =>
|
|
||||||
sharesController.setSharedWithMe(set.first),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 8)),
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||||
|
|
||||||
if (sharesController.isLoading && shares.isEmpty)
|
if (sharesController.isLoading && shares.isEmpty)
|
||||||
const SliverFillRemaining(
|
tabLoadingSliver
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(child: CircularProgressIndicator()),
|
|
||||||
)
|
|
||||||
else if (sharesController.errorMessage != null)
|
else if (sharesController.errorMessage != null)
|
||||||
SliverFillRemaining(
|
tabErrorSliver(
|
||||||
hasScrollBody: false,
|
context,
|
||||||
child: Center(
|
title: 'Could not load shares',
|
||||||
child: Padding(
|
message: sharesController.errorMessage!,
|
||||||
padding: const EdgeInsets.all(24),
|
onRetry: sharesController.fetchAll,
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.error_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Could not load shares',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
sharesController.errorMessage!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: sharesController.fetchAll,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else if (shares.isEmpty)
|
else if (shares.isEmpty)
|
||||||
SliverFillRemaining(
|
tabEmptySliver(
|
||||||
hasScrollBody: false,
|
context,
|
||||||
child: Center(
|
icon: LucideIcons.share2,
|
||||||
child: Column(
|
message: _emptyMessage(),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.groups_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
sharesController.sharedWithMe
|
|
||||||
? 'Nothing has been shared with you'
|
|
||||||
: 'You haven\'t shared anything yet',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
else if (isDesktop)
|
||||||
|
_buildDesktopTable(context, shares)
|
||||||
else
|
else
|
||||||
SliverPadding(
|
_buildMobileList(context, shares),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
...tabBottomInsetSlivers(context),
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final share = shares[index];
|
|
||||||
return _buildShareTile(context, share);
|
|
||||||
}, childCount: shares.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return SyncedHeaderScaffold(
|
return ColoredBox(
|
||||||
scrollController: widget.scrollController,
|
color: colors.bg,
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
child: RefreshIndicator(
|
||||||
onRefresh: sharesController.fetchAll,
|
color: colors.accent,
|
||||||
contentSlivers: contentSlivers,
|
backgroundColor: colors.surface,
|
||||||
);
|
onRefresh: sharesController.fetchAll,
|
||||||
}
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top
|
||||||
Widget _buildShareTile(BuildContext context, NextcloudShare share) {
|
// bar above it fully collapses.
|
||||||
final theme = Theme.of(context);
|
child: SafeArea(
|
||||||
final colorScheme = theme.colorScheme;
|
top: true,
|
||||||
final iconColor = colorScheme.primary;
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
final withWhom = share.sharedWithMe
|
controller: widget.scrollController,
|
||||||
? 'From ${share.ownerDisplayName}'
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
: (share.sharedWithDisplayName != null
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
? 'With ${share.sharedWithDisplayName}'
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
: 'Shared link');
|
slivers: contentSlivers,
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Material(
|
|
||||||
color: colorScheme.surfaceContainerLow,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_shareItemIcon(share.itemType),
|
|
||||||
color: iconColor,
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
share.name,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 2),
|
|
||||||
child: Icon(
|
|
||||||
_shareTypeIcon(share.shareType),
|
|
||||||
size: 13,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'$withWhom • ${DateFormat.yMMMd().format(share.sharedAt)}',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.link_off_rounded, color: colorScheme.error),
|
|
||||||
tooltip: share.sharedWithMe ? 'Remove' : 'Unshare',
|
|
||||||
onPressed: () => _confirmUnshare(context, share),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmUnshare(
|
List<NextcloudShare> _visibleShares(SharesController c) {
|
||||||
|
switch (_scope) {
|
||||||
|
case _ShareScope.withYou:
|
||||||
|
return c.shares;
|
||||||
|
case _ShareScope.byYou:
|
||||||
|
return c.shares
|
||||||
|
.where((s) => s.shareType != ShareType.publicLink)
|
||||||
|
.toList();
|
||||||
|
case _ShareScope.links:
|
||||||
|
return c.shares
|
||||||
|
.where((s) => s.shareType == ShareType.publicLink)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _emptyMessage() {
|
||||||
|
switch (_scope) {
|
||||||
|
case _ShareScope.withYou:
|
||||||
|
return 'Nothing has been shared with you';
|
||||||
|
case _ShareScope.byYou:
|
||||||
|
return "You haven't shared anything yet";
|
||||||
|
case _ShareScope.links:
|
||||||
|
return 'No share links yet';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectScope(SharesController c, _ShareScope scope) {
|
||||||
|
setState(() => _scope = scope);
|
||||||
|
// No-ops (see `SharesController.setSharedWithMe`) when moving between
|
||||||
|
// `byYou`/`links`, which share the same `sharedWithMe: false` fetch.
|
||||||
|
c.setSharedWithMe(scope == _ShareScope.withYou);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lazily-built `SliverList`, not `NooGroupedList` (its own `Column`
|
||||||
|
// isn't lazy - see files_view.dart's `_buildMobileRow` doc comment, and
|
||||||
|
// trash_view.dart's `_buildMobileList`, which had the same bug: a heavy
|
||||||
|
// account's full share list built eagerly up front). Each row still
|
||||||
|
// reads as one continuous radius-20 card via per-row corner rounding +
|
||||||
|
// a 1px `line` divider.
|
||||||
|
Widget _buildMobileList(BuildContext context, List<NextcloudShare> shares) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final share = shares[index];
|
||||||
|
final row = NooFileRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: share.name,
|
||||||
|
isDirectory: share.isFolder,
|
||||||
|
),
|
||||||
|
name: share.name,
|
||||||
|
meta:
|
||||||
|
'${share.ownerDisplayName} · ${_permissionLabel(share.permissions)}',
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
onTap: () => _openShareSheet(context, share),
|
||||||
|
trailing: Icon(_shareTypeIcon(share.shareType), size: 16),
|
||||||
|
);
|
||||||
|
final isFirst = index == 0;
|
||||||
|
final isLast = index == shares.length - 1;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: isFirst
|
||||||
|
? const Radius.circular(NooRadii.card)
|
||||||
|
: Radius.zero,
|
||||||
|
bottom: isLast
|
||||||
|
? const Radius.circular(NooRadii.card)
|
||||||
|
: Radius.zero,
|
||||||
|
),
|
||||||
|
child: row,
|
||||||
|
),
|
||||||
|
if (!isLast) Container(height: 1, color: colors.line),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, childCount: shares.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopTable(BuildContext context, List<NextcloudShare> shares) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.xl,
|
||||||
|
vertical: NooSpace.xs,
|
||||||
|
),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == 0) {
|
||||||
|
return const NooFileTableHeader(
|
||||||
|
col2Label: 'Owner',
|
||||||
|
col3Label: 'Permission',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final share = shares[index - 1];
|
||||||
|
return NooFileTableRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: share.name,
|
||||||
|
isDirectory: share.isFolder,
|
||||||
|
),
|
||||||
|
name: share.name,
|
||||||
|
col2: share.ownerDisplayName,
|
||||||
|
col3: _permissionLabel(share.permissions),
|
||||||
|
onTap: () => _openShareSheet(context, share),
|
||||||
|
);
|
||||||
|
}, childCount: shares.length + 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every row's whole-row tap - a share only carries enough metadata for
|
||||||
|
/// its own row, not the size/dates the Share sheet's header needs, so
|
||||||
|
/// this fetches the real item first (see `FilesController.fetchItemAtPath`)
|
||||||
|
/// rather than opening the sheet straight from [share]. Reused by every
|
||||||
|
/// scope (With you/By you/Links) - the Share sheet is the one place that
|
||||||
|
/// already knows how to copy a link or remove access, so there's no
|
||||||
|
/// separate overflow menu here any more.
|
||||||
|
Future<void> _openShareSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
NextcloudShare share,
|
NextcloudShare share,
|
||||||
) async {
|
) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final files = context.read<FilesController>();
|
||||||
context: context,
|
|
||||||
builder: (dialogContext) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text(share.sharedWithMe ? 'Remove Share' : 'Unshare'),
|
|
||||||
content: Text(
|
|
||||||
share.sharedWithMe
|
|
||||||
? 'Remove "${share.name}" shared with you by ${share.ownerDisplayName}?'
|
|
||||||
: 'Stop sharing "${share.name}"?',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, false),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
|
||||||
child: const Text('Remove'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (confirmed != true || !context.mounted) return;
|
|
||||||
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final success = await context.read<SharesController>().deleteShare(
|
final item = await files.fetchItemAtPath(share.path);
|
||||||
share,
|
if (!context.mounted) return;
|
||||||
);
|
if (item == null) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text(
|
content: Text('Could not find this item - it may have moved'),
|
||||||
success
|
behavior: SnackBarBehavior.floating,
|
||||||
? 'Removed share for ${share.name}'
|
|
||||||
: 'Failed to remove share for ${share.name}',
|
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
);
|
||||||
),
|
return;
|
||||||
);
|
}
|
||||||
|
await ShareSheet.show(context, item);
|
||||||
|
// The sheet manages shares through `ItemOperations`, not this tab's own
|
||||||
|
// `SharesController` (see its `deleteShare`'s doc comment) - refetch so
|
||||||
|
// a share added/removed from inside it doesn't leave this list stale.
|
||||||
|
if (context.mounted) context.read<SharesController>().fetchAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../providers/trash_controller.dart';
|
import '../providers/trash_controller.dart';
|
||||||
import '../widgets/more_tabs_button.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/profile_avatar_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
IconData _trashItemIcon(NextcloudItemType type) {
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
switch (type) {
|
import '../widgets/noo/files/noo_file_tile.dart';
|
||||||
case NextcloudItemType.folder:
|
import '../widgets/noo/lists/noo_banner.dart';
|
||||||
return Icons.folder_rounded;
|
import '../widgets/noo/noo_layout.dart';
|
||||||
case NextcloudItemType.image:
|
import '../widgets/tabs/tab_location.dart';
|
||||||
return Icons.image_rounded;
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
case NextcloudItemType.video:
|
|
||||||
return Icons.movie_rounded;
|
|
||||||
case NextcloudItemType.audio:
|
|
||||||
return Icons.audiotrack_rounded;
|
|
||||||
case NextcloudItemType.document:
|
|
||||||
return Icons.description_rounded;
|
|
||||||
case NextcloudItemType.archive:
|
|
||||||
return Icons.folder_zip_rounded;
|
|
||||||
case NextcloudItemType.file:
|
|
||||||
return Icons.insert_drive_file_rounded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// The Trash tab: a retention banner (`NooBanner`, with "Empty trash" as
|
||||||
|
/// its danger action) then the trashed-items list, per `DESIGN_SYSTEM.md`
|
||||||
|
/// §4. There's no dedicated "empty trash" endpoint anywhere in
|
||||||
|
/// [TrashController]/`NextcloudService` - [_emptyTrash] composes it from
|
||||||
|
/// the existing per-item [TrashController.deleteForever], the same way
|
||||||
|
/// `FavoritesView._confirmDeleteSelected` builds its bulk delete from a
|
||||||
|
/// per-item call.
|
||||||
class TrashView extends StatefulWidget {
|
class TrashView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const TrashView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const TrashView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TrashView> createState() => _TrashViewState();
|
State<TrashView> createState() => _TrashViewState();
|
||||||
@@ -40,8 +40,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final colors = context.nooColors;
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final trashController = context.watch<TrashController>();
|
final trashController = context.watch<TrashController>();
|
||||||
|
|
||||||
if (!_requested) {
|
if (!_requested) {
|
||||||
@@ -52,176 +51,189 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final trash = trashController.items;
|
final trash = trashController.items;
|
||||||
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
|
SliverPadding(
|
||||||
if (trashController.isLoading && trash.isEmpty)
|
padding: EdgeInsets.fromLTRB(
|
||||||
const SliverFillRemaining(
|
NooLayout.gutter(context),
|
||||||
hasScrollBody: false,
|
NooSpace.md,
|
||||||
child: Center(child: CircularProgressIndicator()),
|
NooLayout.gutter(context),
|
||||||
)
|
0,
|
||||||
else if (trashController.errorMessage != null)
|
),
|
||||||
SliverFillRemaining(
|
sliver: SliverToBoxAdapter(
|
||||||
hasScrollBody: false,
|
child: NooBanner(
|
||||||
child: Center(
|
actionLabel: trash.isEmpty ? null : 'Empty trash',
|
||||||
child: Padding(
|
onAction: trash.isEmpty
|
||||||
padding: const EdgeInsets.all(24),
|
? null
|
||||||
child: Column(
|
: () => _confirmEmptyTrash(context, trash),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: const Text(
|
||||||
children: [
|
'Deleted items are kept for 30 days, then removed automatically.',
|
||||||
Icon(
|
|
||||||
Icons.error_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Could not load trash',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
trashController.errorMessage!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: trashController.fetchAll,
|
|
||||||
icon: const Icon(Icons.refresh_rounded),
|
|
||||||
label: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
else if (trash.isEmpty)
|
|
||||||
SliverFillRemaining(
|
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.delete_outline_rounded,
|
|
||||||
size: 64,
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Trash is empty',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = trash[index];
|
|
||||||
return _buildTrashTile(context, item);
|
|
||||||
}, childCount: trash.length),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
if (trashController.isLoading && trash.isEmpty)
|
||||||
|
tabLoadingSliver
|
||||||
|
else if (trashController.errorMessage != null)
|
||||||
|
tabErrorSliver(
|
||||||
|
context,
|
||||||
|
title: 'Could not load trash',
|
||||||
|
message: trashController.errorMessage!,
|
||||||
|
onRetry: trashController.fetchAll,
|
||||||
|
)
|
||||||
|
else if (trash.isEmpty)
|
||||||
|
tabEmptySliver(
|
||||||
|
context,
|
||||||
|
icon: LucideIcons.trash2,
|
||||||
|
message: 'Trash is empty',
|
||||||
|
)
|
||||||
|
else if (isDesktop)
|
||||||
|
_buildDesktopTable(context, trash)
|
||||||
|
else
|
||||||
|
_buildMobileList(context, trash),
|
||||||
|
...tabBottomInsetSlivers(context),
|
||||||
];
|
];
|
||||||
|
|
||||||
return SyncedHeaderScaffold(
|
return ColoredBox(
|
||||||
scrollController: widget.scrollController,
|
color: colors.bg,
|
||||||
actions: const [MoreTabsButton(), ProfileAvatarButton()],
|
child: RefreshIndicator(
|
||||||
onRefresh: trashController.fetchAll,
|
color: colors.accent,
|
||||||
contentSlivers: contentSlivers,
|
backgroundColor: colors.surface,
|
||||||
);
|
onRefresh: trashController.fetchAll,
|
||||||
}
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
|
// controls row rides up under the status bar once the floating top
|
||||||
Widget _buildTrashTile(BuildContext context, NextcloudItem item) {
|
// bar above it fully collapses.
|
||||||
final theme = Theme.of(context);
|
child: SafeArea(
|
||||||
final colorScheme = theme.colorScheme;
|
top: true,
|
||||||
final iconColor = colorScheme.outline;
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
return Padding(
|
controller: widget.scrollController,
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
child: Material(
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
color: colorScheme.surfaceContainerLow,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
borderRadius: BorderRadius.circular(16),
|
slivers: contentSlivers,
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconColor.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_trashItemIcon(item.type),
|
|
||||||
color: iconColor,
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
item.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
item.deletedAt != null
|
|
||||||
? 'Deleted ${DateFormat.yMMMd().format(item.deletedAt!)}'
|
|
||||||
: 'Deleted',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.restore_rounded),
|
|
||||||
tooltip: 'Restore',
|
|
||||||
color: colorScheme.primary,
|
|
||||||
onPressed: () => _restore(context, item),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.delete_forever_rounded,
|
|
||||||
color: colorScheme.error,
|
|
||||||
),
|
|
||||||
tooltip: 'Delete forever',
|
|
||||||
onPressed: () => _confirmDeleteForever(context, item),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A lazily-built `SliverList`, not `NooGroupedList` (its own `Column`
|
||||||
|
// isn't lazy - see files_view.dart's `_buildMobileRow` doc comment for
|
||||||
|
// the same reasoning): Trash can hold hundreds of items, and building
|
||||||
|
// every row eagerly up front is what made this tab laggy. Each row still
|
||||||
|
// reads as one continuous radius-20 card via per-row corner rounding +
|
||||||
|
// a 1px `line` divider, matching `NooGroupedList`'s look.
|
||||||
|
Widget _buildMobileList(BuildContext context, List<NextcloudItem> trash) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = trash[index];
|
||||||
|
// The overflow menu's only action here is "Delete forever" - a
|
||||||
|
// bare ellipsis obscures that behind an extra tap to discover it,
|
||||||
|
// so this row shows the delete icon directly instead of going
|
||||||
|
// through `NooFileRow.onMore`.
|
||||||
|
final row = NooFileRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
),
|
||||||
|
name: item.name,
|
||||||
|
meta: _meta(item),
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
label: 'Restore',
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: () => _restore(context, item),
|
||||||
|
radius: 20,
|
||||||
|
child: const Icon(LucideIcons.rotateCcw),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: NooSpace.xs),
|
||||||
|
Semantics(
|
||||||
|
button: true,
|
||||||
|
label: 'Delete forever',
|
||||||
|
child: InkResponse(
|
||||||
|
onTap: () => _confirmDeleteForever(context, item),
|
||||||
|
radius: 20,
|
||||||
|
child: Icon(LucideIcons.trash2, color: colors.danger),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final isFirst = index == 0;
|
||||||
|
final isLast = index == trash.length - 1;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: isFirst
|
||||||
|
? const Radius.circular(NooRadii.card)
|
||||||
|
: Radius.zero,
|
||||||
|
bottom: isLast
|
||||||
|
? const Radius.circular(NooRadii.card)
|
||||||
|
: Radius.zero,
|
||||||
|
),
|
||||||
|
child: row,
|
||||||
|
),
|
||||||
|
if (!isLast) Container(height: 1, color: colors.line),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, childCount: trash.length),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDesktopTable(BuildContext context, List<NextcloudItem> trash) {
|
||||||
|
return SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.xl,
|
||||||
|
vertical: NooSpace.xs,
|
||||||
|
),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == 0) {
|
||||||
|
return const NooFileTableHeader(
|
||||||
|
col2Label: 'Deleted',
|
||||||
|
col3Label: 'Original location',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final item = trash[index - 1];
|
||||||
|
return _TrashDesktopRow(
|
||||||
|
kind: NooFileKind.from(
|
||||||
|
name: item.name,
|
||||||
|
mimeType: item.mimeType,
|
||||||
|
isDirectory: item.isFolder,
|
||||||
|
),
|
||||||
|
name: item.name,
|
||||||
|
col2: _deletedLabel(item),
|
||||||
|
col3: item.originalLocation ?? 'Unknown location',
|
||||||
|
onRestore: () => _restore(context, item),
|
||||||
|
onDelete: () => _confirmDeleteForever(context, item),
|
||||||
|
);
|
||||||
|
}, childCount: trash.length + 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _meta(NextcloudItem item) =>
|
||||||
|
'Deleted ${_deletedLabel(item)} · ${tabLocationLabel(item.originalLocation ?? item.path, item.name)}';
|
||||||
|
|
||||||
|
String _deletedLabel(NextcloudItem item) => item.deletedAt != null
|
||||||
|
? DateFormat.yMMMd().format(item.deletedAt!)
|
||||||
|
: 'recently';
|
||||||
|
|
||||||
Future<void> _restore(BuildContext context, NextcloudItem item) async {
|
Future<void> _restore(BuildContext context, NextcloudItem item) async {
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final success = await context.read<TrashController>().restore(item);
|
final success = await context.read<TrashController>().restore(item);
|
||||||
@@ -243,7 +255,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Delete Forever'),
|
title: const Text('Delete forever'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'Permanently delete "${item.name}"? This cannot be undone.',
|
'Permanently delete "${item.name}"? This cannot be undone.',
|
||||||
),
|
),
|
||||||
@@ -257,7 +269,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(dialogContext, true),
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
child: const Text('Delete Forever'),
|
child: const Text('Delete forever'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -276,4 +288,159 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmEmptyTrash(
|
||||||
|
BuildContext context,
|
||||||
|
List<NextcloudItem> trash,
|
||||||
|
) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Empty trash'),
|
||||||
|
content: Text(
|
||||||
|
'Permanently delete all ${trash.length} item(s) in trash? This cannot be undone.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(dialogContext).colorScheme.error,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Empty trash'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
|
final controller = context.read<TrashController>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
var succeeded = 0;
|
||||||
|
for (final item in trash) {
|
||||||
|
if (await controller.deleteForever(item)) succeeded++;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Deleted $succeeded of ${trash.length} item(s)'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
|
||||||
|
/// holds a tonal "Restore" button plus a danger-tonal "Delete" button
|
||||||
|
/// (permanent delete) instead of status icons - `NooFileTableRow` has no
|
||||||
|
/// slot for screen-specific actions there. A labelled pill, not a bare
|
||||||
|
/// overflow icon: it's the row's only destructive action, not a menu of
|
||||||
|
/// several, and desktop already prefers labelled buttons to bare icons
|
||||||
|
/// (see `NooSelectionBar`'s own desktop actions). Column widths
|
||||||
|
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
|
||||||
|
/// `noo_file_table.dart`) so this still lines up under it; promoting an
|
||||||
|
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
|
||||||
|
/// into the shared component.
|
||||||
|
class _TrashDesktopRow extends StatelessWidget {
|
||||||
|
final NooFileKind kind;
|
||||||
|
final String name;
|
||||||
|
final String col2;
|
||||||
|
final String col3;
|
||||||
|
final VoidCallback onRestore;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _TrashDesktopRow({
|
||||||
|
required this.kind,
|
||||||
|
required this.name,
|
||||||
|
required this.col2,
|
||||||
|
required this.col3,
|
||||||
|
required this.onRestore,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: NooSizes.rowDesktop,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
|
||||||
|
const SizedBox(width: NooSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.body.copyWith(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: NooSpace.md),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: Text(
|
||||||
|
col2,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 160,
|
||||||
|
child: Text(
|
||||||
|
col3,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
NooButton(
|
||||||
|
variant: NooButtonVariant.tonal,
|
||||||
|
size: NooButtonSize.xs,
|
||||||
|
onTap: onRestore,
|
||||||
|
child: const Text('Restore'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
NooButton(
|
||||||
|
variant: NooButtonVariant.danger,
|
||||||
|
size: NooButtonSize.xs,
|
||||||
|
onTap: onDelete,
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/app_tab.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
|
import '../providers/session_controller.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
|
import '../providers/trash_controller.dart';
|
||||||
|
import 'noo/core/noo_avatar.dart';
|
||||||
|
import 'noo/nav/noo_drawer.dart';
|
||||||
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
|
/// The mobile drawer (DESIGN_SYSTEM.md 2 "Drawer" / 3; `drawer` in
|
||||||
|
/// `Mobile Screen.dc.html`): account block, storage bar, the tabs not
|
||||||
|
/// pinned to the bottom nav bar ("More"), Settings, and an "Edit tabs"
|
||||||
|
/// link. Opened the same way as before (menu icon in [AppTopBar]), but
|
||||||
|
/// built on [NooDrawer] instead of the old Material [Drawer] + ad hoc rows.
|
||||||
|
///
|
||||||
|
/// Tapping a "More" tab switches the shell straight to it (rather than
|
||||||
|
/// pushing it as a standalone screen, like the pre-rework `MoreTabsButton`
|
||||||
|
/// did) via `SettingsController.requestTab` - the same one-shot mechanism
|
||||||
|
/// `SearchView`/`FavoritesView`/`ShareUploadView` already use to jump the
|
||||||
|
/// shell to a tab from outside it, so `MainShellView` needs no extra
|
||||||
|
/// plumbing to support it.
|
||||||
|
class AppDrawer extends StatelessWidget {
|
||||||
|
const AppDrawer({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final session = context.watch<SessionController>();
|
||||||
|
final settings = context.watch<SettingsController>();
|
||||||
|
final quota = context.watch<FilesController>().quota;
|
||||||
|
// Cheap and already loaded when the Trash tab has been visited this
|
||||||
|
// session - not worth a fetch just to populate a drawer badge.
|
||||||
|
final trashCount = context.watch<TrashController>().items.length;
|
||||||
|
final hiddenTabs = settings.tabOrder
|
||||||
|
.where((t) => settings.hiddenTabs.contains(t))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
void closeAndOpenSettings() {
|
||||||
|
Navigator.pop(context);
|
||||||
|
openSettings(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NooDrawer(
|
||||||
|
account: NooDrawerAccount(
|
||||||
|
avatar: NooAvatar(
|
||||||
|
initials: accountInitial(session.username),
|
||||||
|
current: true,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
name: session.username,
|
||||||
|
subtitle: serverHost(session.serverUrl),
|
||||||
|
onTap: closeAndOpenSettings,
|
||||||
|
onSwitchAccount: () => showAccountSwitcher(context),
|
||||||
|
),
|
||||||
|
storage: NooDrawerStorage(
|
||||||
|
value: quotaFraction(quota),
|
||||||
|
label: quotaUsedLabel(quota),
|
||||||
|
),
|
||||||
|
moreItems: [
|
||||||
|
for (final tab in hiddenTabs)
|
||||||
|
NooDrawerItem(
|
||||||
|
icon: tab.icon,
|
||||||
|
label: tab.label,
|
||||||
|
count: tab == AppTab.trash && trashCount > 0
|
||||||
|
? '$trashCount'
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
settings.requestTab(tab);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
settings: NooDrawerItem(
|
||||||
|
icon: LucideIcons.settings,
|
||||||
|
label: 'Settings',
|
||||||
|
onTap: closeAndOpenSettings,
|
||||||
|
),
|
||||||
|
footer: NooDrawerLink(onTap: closeAndOpenSettings),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||