Compare commits
@@ -25,7 +25,7 @@ lib/
|
|||||||
noo/ # the Noo design-system component kit - see
|
noo/ # the Noo design-system component kit - see
|
||||||
# styling.md's catalog
|
# 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 phone/tablet app shell
|
shell/ # pieces shared by the mobile/desktop app shell
|
||||||
settings/ # Settings' section widgets
|
settings/ # Settings' section widgets
|
||||||
tabs/ # loading/error/empty slivers + grouping helpers
|
tabs/ # loading/error/empty slivers + grouping helpers
|
||||||
# shared by Recent/Activity/Trash/Shares
|
# shared by Recent/Activity/Trash/Shares
|
||||||
@@ -57,15 +57,16 @@ new provider instance):
|
|||||||
saved-accounts list, which one is active, `sessionGeneration` — see
|
saved-accounts list, which one is active, `sessionGeneration` — see
|
||||||
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
||||||
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
||||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
|
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
|
||||||
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||||
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
||||||
controllers — constructed after `SessionController` and unable to hold a
|
controllers — constructed after `SessionController` and unable to hold a
|
||||||
forward reference to it — can react to login/logout/account-switch
|
forward reference to it — can react to login/logout/account-switch
|
||||||
without a circular dependency.
|
without a circular dependency.
|
||||||
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
|
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
|
||||||
global UI prefs independent of login state: theme mode/seed color/dynamic
|
global UI prefs independent of login state: theme mode/seed color/dynamic
|
||||||
color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab
|
color, bottom-bar opacity/blur, avatar and selection action-bar positions,
|
||||||
|
tap-tab-to-scroll-top, seek bar style, tab
|
||||||
order/visibility/default (`requestedTab`/`requestTab`/
|
order/visibility/default (`requestedTab`/`requestTab`/
|
||||||
`consumeRequestedTab`), swipe actions.
|
`consumeRequestedTab`), swipe actions.
|
||||||
- [`FilesController`](../../lib/providers/files_controller.dart) — the
|
- [`FilesController`](../../lib/providers/files_controller.dart) — the
|
||||||
@@ -237,7 +238,7 @@ added - an existing saved tab order from before a new tab existed), so
|
|||||||
adding a tab to the `AppTab` enum needs no extra migration. Only the (up
|
adding a tab to the `AppTab` enum needs no extra migration. Only the (up
|
||||||
to) 5 pinned tabs (`settings.visibleTabs`) become bottom-nav/sidebar
|
to) 5 pinned tabs (`settings.visibleTabs`) become bottom-nav/sidebar
|
||||||
destinations; the rest sit in the "More" section of the drawer (mobile) or
|
destinations; the rest sit in the "More" section of the drawer (mobile) or
|
||||||
sidebar (wide tablet) - see below.
|
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
|
||||||
@@ -276,17 +277,19 @@ bare system prompt gives no context on its own.
|
|||||||
`MainShellView` builds its chrome from the Noo nav kit
|
`MainShellView` builds its chrome from the Noo nav kit
|
||||||
(`widgets/noo/nav/`) and switches between two layouts on
|
(`widgets/noo/nav/`) and switches between two layouts on
|
||||||
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
||||||
phone in landscape keeps the mobile layout and only tablet-class viewports get
|
phone in landscape keeps the mobile layout and only tablet-class windows get
|
||||||
the sidebar):
|
the sidebar):
|
||||||
|
|
||||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
for *every* tab (previously only Files had shell-level top chrome, with
|
for *every* tab (previously only Files had shell-level top chrome, with
|
||||||
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
|
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
|
||||||
large title, an inline search field, a `plus` action on Files only (no
|
large title with Upload and the top account avatar on the same row,
|
||||||
other tab has a create/upload flow), and the account avatar; Android
|
and an inline search field below; Android
|
||||||
gets a compact title row with `search`/avatar actions, relying on an
|
gets a compact title row with `search`/avatar actions, relying on an
|
||||||
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
extended `NooFab` instead of a top-bar upload button. Both use the same
|
||||||
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
|
size preference: Auto expands on Files/Photos and collapses elsewhere,
|
||||||
|
Mini always collapses, and Expanded always shows Upload. Rather than one
|
||||||
|
shared instance in `Scaffold.appBar`,
|
||||||
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
||||||
tab) and each tab plants its own as the first sliver in its own
|
tab) and each tab plants its own as the first sliver in its own
|
||||||
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
||||||
@@ -295,7 +298,7 @@ the sidebar):
|
|||||||
native "floating" app bar behavior, tied to that tab's own
|
native "floating" app bar behavior, tied to that tab's own
|
||||||
`ScrollController`: scrolls away as the list scrolls down and reappears
|
`ScrollController`: scrolls away as the list scrolls down and reappears
|
||||||
the moment the drag reverses, not only once scrolled back to the top).
|
the moment the drag reverses, not only once scrolled back to the top).
|
||||||
`topBar` is null (no top bar rendered) on wide tablet and while picking,
|
`topBar` is null (no top bar rendered) on desktop and while picking,
|
||||||
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
|
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
|
||||||
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
|
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
|
||||||
in-content header (the sort/filter controls row, or Files/Photos'
|
in-content header (the sort/filter controls row, or Files/Photos'
|
||||||
@@ -312,9 +315,14 @@ the sidebar):
|
|||||||
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||||
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||||
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||||
instead, a dropdown holding the same hidden-tabs + Settings content -
|
instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
|
||||||
see `styling.md`'s Gotchas for the wiring.
|
card from the top or bottom avatar; `AvatarNavigationBody` moves only
|
||||||
- **Wide tablet:** a `NooSidebar` (account card, pinned tabs, divider,
|
content for the top popup. The bottom popup overlays content. Floating
|
||||||
|
mode moves the avatar into the popup and hands its satellite slot to
|
||||||
|
Search; attached mode keeps the avatar and Search in the full-width bar
|
||||||
|
and expands the popup upward from it. Position is a global setting.
|
||||||
|
See `styling.md` for motion and dismissal behavior.
|
||||||
|
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
||||||
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
||||||
(tab title, search, an "Upload" action on Files/Photos) over the same
|
(tab title, search, an "Upload" action on Files/Photos) over the same
|
||||||
`IndexedStack`, both built inline in `main.dart` rather than as separate
|
`IndexedStack`, both built inline in `main.dart` rather than as separate
|
||||||
@@ -331,7 +339,7 @@ pinned or another "More" tab. `widgets/shell/shell_common.dart` holds the
|
|||||||
pieces both layouts share: account/storage formatting, `openSettings`/
|
pieces both layouts share: account/storage formatting, `openSettings`/
|
||||||
`openSearch`, `showAccountSwitcher` (the saved-accounts list behind the
|
`openSearch`, `showAccountSwitcher` (the saved-accounts list behind the
|
||||||
drawer's chevron and the sidebar's account card - a sheet on mobile, a
|
drawer's chevron and the sidebar's account card - a sheet on mobile, a
|
||||||
dialog on wide tablet), `ShellAvatarButton` and `ShellSearchLauncher`.
|
dialog on desktop), `ShellAvatarButton` and `ShellSearchLauncher`.
|
||||||
|
|
||||||
All eight tabs used to share
|
All eight tabs used to share
|
||||||
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) - a
|
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) - a
|
||||||
@@ -346,9 +354,9 @@ pinned chip. `SyncedHeaderScaffold` is now unused - the last two screens on
|
|||||||
it, `ShareUploadView` (the share-to-upload destination picker, pushed
|
it, `ShareUploadView` (the share-to-upload destination picker, pushed
|
||||||
rather than a tab - see below) and `MoveCopyDestinationPicker`, are
|
rather than a tab - see below) and `MoveCopyDestinationPicker`, are
|
||||||
rebuilt too: both are a `Scaffold` with a `NooTopBar`+`NooTopBarBack`
|
rebuilt too: both are a `Scaffold` with a `NooTopBar`+`NooTopBarBack`
|
||||||
(mobile) / `NooToolbar` (wide tablet) top bar, a noo-styled
|
(mobile) / `NooToolbar` (desktop) top bar, a noo-styled
|
||||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
|
||||||
(`NooFileRow` mobile, `NooFileTableRow` wide tablet - both pickers only ever
|
(`NooFileRow` mobile, `NooFileTableRow` desktop - both pickers only ever
|
||||||
browse *folders*, so the old dimmed-but-visible file rows are gone; a
|
browse *folders*, so the old dimmed-but-visible file rows are gone; a
|
||||||
`RefreshIndicator`+`CustomScrollView`) for a body, and a `colors.surface`
|
`RefreshIndicator`+`CustomScrollView`) for a body, and a `colors.surface`
|
||||||
bottom bar with a top `line` (no more rounded-top elevated `Material`
|
bottom bar with a top `line` (no more rounded-top elevated `Material`
|
||||||
@@ -393,14 +401,14 @@ per-item conflict follow-up, if any, is
|
|||||||
`SearchView`, `AccountView` (Settings), the file-details sheet
|
`SearchView`, `AccountView` (Settings), the file-details sheet
|
||||||
(`DetailsSheet`), and the share sheet (`ShareSheet`) are pushed on top via
|
(`DetailsSheet`), and the share sheet (`ShareSheet`) are pushed on top via
|
||||||
`Navigator`/`showNooSheet`/`showNooDialog` rather than being tabs (the two
|
`Navigator`/`showNooSheet`/`showNooDialog` rather than being tabs (the two
|
||||||
per-item sheets pick between the mobile sheet and the wide tablet dialog via
|
per-item sheets pick between the mobile sheet and the desktop dialog via
|
||||||
`NooLayout.isDesktop`, same as every other overlay in the app;
|
`NooLayout.isDesktop`, same as every other overlay in the app;
|
||||||
`showGradualBottomSheet`, the custom drag-to-resize sheet they used before,
|
`showGradualBottomSheet`, the custom drag-to-resize sheet they used before,
|
||||||
has no remaining callers). On mobile, `ShellAvatarButton`
|
has no remaining callers). On mobile, `ShellAvatarButton`
|
||||||
(`widgets/shell/shell_common.dart`,
|
(`widgets/shell/shell_common.dart`,
|
||||||
shown in `AppTopBar`'s trailing actions on every tab, and reused directly by
|
shown in `AppTopBar`'s trailing actions on every tab, and reused directly by
|
||||||
`ShareUploadView`) opens Settings on tap and cycles between saved accounts
|
`ShareUploadView`) opens Settings on tap and cycles between saved accounts
|
||||||
on a vertical swipe. Wide tablet has no avatar in the
|
on a vertical swipe. Desktop has no avatar in the
|
||||||
toolbar; `NooSidebarAccount`'s account card opens the full switcher instead
|
toolbar; `NooSidebarAccount`'s account card opens the full switcher instead
|
||||||
(`showAccountSwitcher`, the same one behind the mobile drawer's chevron).
|
(`showAccountSwitcher`, the same one behind the mobile drawer's chevron).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Noo Design System
|
# Noo Design System
|
||||||
|
|
||||||
Noo is a Nextcloud client for iOS and Android. ("Noo" is a placeholder name.)
|
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.
|
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.
|
**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.
|
||||||
@@ -9,8 +9,8 @@ Reference files in this project:
|
|||||||
- `Noo Screens.dc.html` has every screen on every platform, in light and dark.
|
- `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 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.
|
- `noo-kit.js` holds the tokens (`TH`), the icon set (`SVG`/`ic`), file-type mapping (`KIND`) and mock data.
|
||||||
- `Mobile Screen.dc.html` is the reference build for each screen.
|
- `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 + wide tablet). 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.
|
- [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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ Reference files in this project:
|
|||||||
|
|
||||||
| Token | Role | Light | Dark |
|
| Token | Role | Light | Dark |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `bg` | App background, wide tablet sidebar | `#F3EFE6` warm off-white | `#0E0D0B` |
|
| `bg` | App background, desktop sidebar | `#F3EFE6` warm off-white | `#0E0D0B` |
|
||||||
| `surface` (`sf`) | Cards, rows, bars, sheets, wide tablet main pane | `#FFFFFF` | `#25241F` |
|
| `surface` (`sf`) | Cards, rows, bars, sheets, desktop main pane | `#FFFFFF` | `#25241F` |
|
||||||
| `surface-2` (`sf2`) | Inputs, inset panels, secondary buttons | `#F6F5F2` | `#3D3C38` |
|
| `surface-2` (`sf2`) | Inputs, inset panels, secondary buttons | `#F6F5F2` | `#3D3C38` |
|
||||||
| `surface-3` (`sf3`) | Progress tracks, toggle off, grabbers | `#EAE8E2` | `#5A5852` |
|
| `surface-3` (`sf3`) | Progress tracks, toggle off, grabbers | `#EAE8E2` | `#5A5852` |
|
||||||
| `line` (`ln`) | Dividers, 1px borders | `#EAE8E2` | `#3D3C38` |
|
| `line` (`ln`) | Dividers, 1px borders | `#EAE8E2` | `#3D3C38` |
|
||||||
@@ -54,7 +54,7 @@ Rules:
|
|||||||
| image | info-soft | info | `image` |
|
| image | info-soft | info | `image` |
|
||||||
| archive | surface-3 | fg-2 | `file-archive` |
|
| archive | surface-3 | fg-2 | `file-archive` |
|
||||||
|
|
||||||
Tile sizes: 40px/r12 for mobile rows, 30px/r9 for wide tablet rows, 44px/r12 for the share header, 32px/r10 for activity. The icon is half the tile size (20, 16, 24, 16).
|
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
|
### 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.
|
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.
|
||||||
@@ -62,30 +62,30 @@ Fonts (Google Fonts): **Schibsted Grotesk** for headings (weights 500/600) and *
|
|||||||
| Style | Font | Size / line-height | Tracking | Use |
|
| Style | Font | Size / line-height | Tracking | Use |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Large title | Schibsted 600 | 34 / 0.9 | −3% | iOS large title |
|
| Large title | Schibsted 600 | 34 / 0.9 | −3% | iOS large title |
|
||||||
| Stat | Schibsted 600 | 32 (mobile) · 28 (wide tablet) / 0.9 | −3% | Offline GB, counters |
|
| Stat | Schibsted 600 | 32 (mobile) · 28 (desktop) / 0.9 | −3% | Offline GB, counters |
|
||||||
| Title | Schibsted 500 | 22 / 1 | −2% | Android top bar, wide tablet toolbar |
|
| 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 |
|
| Card title | Schibsted 500 | 17–20 / 1 | −2% | Settings cards, sheet sections, account name |
|
||||||
| Group heading | Schibsted 500 | 18 / 1 | −2% | Photos month |
|
| Group heading | Schibsted 500 | 18 / 1 | −2% | Photos month |
|
||||||
| Body L | Instrument 400/500 | 16 / 1.2 | 0 | Mobile row title, settings label |
|
| Body L | Instrument 400/500 | 16 / 1.2 | 0 | Mobile row title, settings label |
|
||||||
| Body | Instrument 400/500 | 14–15 / 1.3 | 0 | Wide tablet rows, activity text, banners |
|
| 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) |
|
| Label | Instrument 600 | 13 / 1 | 0 | Section labels (fg-2), chips, table headers (12, fg-3) |
|
||||||
| Meta | Instrument 400 | 13 (mobile) · 12–13 (wide tablet) / 1.2 | 0 | Size · date, subtitles (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 |
|
| Nav label | Instrument 500 / 600 (active) | 12 Android · 10 iOS | 0 | Bottom bars |
|
||||||
| Button | Instrument 600 | 14–16 / 1 | 0 | All buttons |
|
| Button | Instrument 600 | 14–16 / 1 | 0 | All buttons |
|
||||||
| Mono | ui-monospace 400 | 13–14 | 0 | Share URLs, local paths |
|
| Mono | ui-monospace 400 | 13–14 | 0 | Share URLs, local paths |
|
||||||
|
|
||||||
### 1.4 Space, radius, elevation, motion
|
### 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). Wide tablet content gutter is **24px**.
|
- **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:**
|
- **Radius:**
|
||||||
- pills (`999px`) for every button, chip, segmented control, toggle, search field and badge
|
- pills (`999px`) for every button, chip, segmented control, toggle, search field and badge
|
||||||
- 20px for cards and grouped lists
|
- 20px for cards and grouped lists
|
||||||
- 18px for grid cards and stat cards
|
- 18px for grid cards and stat cards
|
||||||
- 14px for inputs and inset panels
|
- 14px for inputs and inset panels
|
||||||
- 12px for wide tablet sidebar items and selected table rows
|
- 12px for desktop sidebar items and selected table rows
|
||||||
- 28px for the sheet top and drawer edge
|
- 28px for the sheet top and drawer edge
|
||||||
- 24px for wide tablet dialogs
|
- 24px for desktop dialogs
|
||||||
- screen corners are 48 on iOS and 36 on Android
|
- 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 wide tablet dialogs. Separate things by surface color, not by shadow.
|
- **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.
|
- **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
|
### 1.5 Icons
|
||||||
@@ -96,7 +96,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
## 2. Components
|
## 2. Components
|
||||||
|
|
||||||
**Button**
|
**Button**
|
||||||
- Always a pill. Heights: 52 (sheet CTA), 44 (mobile card), 40 (inline in a field), 36 (wide tablet toolbar), 28–32 (compact).
|
- Always a pill. Heights: 52 (sheet CTA), 44 (mobile card), 40 (inline in a field), 36 (desktop toolbar), 28–32 (compact).
|
||||||
- Variants:
|
- Variants:
|
||||||
- *Primary*: accent fill, white text.
|
- *Primary*: accent fill, white text.
|
||||||
- *Tonal*: accent-soft fill, accent-text.
|
- *Tonal*: accent-soft fill, accent-text.
|
||||||
@@ -124,7 +124,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
|
|
||||||
**Chip**
|
**Chip**
|
||||||
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
|
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
|
||||||
- Idle: surface fill on mobile, or a 1px line border on wide tablet.
|
- Idle: surface fill on mobile, or a 1px line border on desktop.
|
||||||
- Selected: accent-soft fill with accent-text.
|
- 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.
|
- A trailing `chevron-down` means it opens a menu. A trailing `x` means it's an active filter.
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
- Use it for the List/Grid toggle (icons only, 36×28), Shares scope, Theme, and default view.
|
- Use it for the List/Grid toggle (icons only, 36×28), Shares scope, Theme, and default view.
|
||||||
|
|
||||||
**Toggle**
|
**Toggle**
|
||||||
- 48×28 on mobile, 44×26 on wide tablet, with a white knob 3px inset.
|
- 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.
|
- On: accent track. Off: surface-3 track. It looks the same on every platform.
|
||||||
|
|
||||||
**Search field**
|
**Search field**
|
||||||
@@ -143,7 +143,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
- Placement:
|
- Placement:
|
||||||
- iOS: below the large title.
|
- iOS: below the large title.
|
||||||
- Android: an icon in the top bar that expands.
|
- Android: an icon in the top bar that expands.
|
||||||
- Wide tablet: 260px wide in the toolbar.
|
- Desktop: 260px wide in the toolbar.
|
||||||
|
|
||||||
**Grouped list (card)**
|
**Grouped list (card)**
|
||||||
- A surface card with radius 20 and 1px gaps showing `line`, which draws the dividers.
|
- A surface card with radius 20 and 1px gaps showing `line`, which draws the dividers.
|
||||||
@@ -156,7 +156,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
- The meta line is `[status icon][shared icon] size · modified`.
|
- The meta line is `[status icon][shared icon] size · modified`.
|
||||||
- A favorite shows an accent-text star before the overflow menu.
|
- A favorite shows an accent-text star before the overflow menu.
|
||||||
- Trailing icon by screen: star in Favorites, `rotate-ccw` in Trash.
|
- Trailing icon by screen: star in Favorites, `rotate-ccw` in Trash.
|
||||||
- Wide tablet: a table row 52px tall.
|
- 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.
|
- 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.
|
- 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.
|
- A selected row gets an accent-soft fill with radius 12.
|
||||||
@@ -179,12 +179,12 @@ multi-select)
|
|||||||
never a second bar stacked underneath it, and never the shell's own top
|
never a second bar stacked underneath it, and never the shell's own top
|
||||||
bar/toolbar, which don't change for selection.
|
bar/toolbar, which don't change for selection.
|
||||||
- A `surface` card, radius 20, in the screen's usual gutter (12 mobile, 24
|
- A `surface` card, radius 20, in the screen's usual gutter (12 mobile, 24
|
||||||
wide tablet) - selection reads as a distinct mode, not a bare row of buttons
|
desktop) - selection reads as a distinct mode, not a bare row of buttons
|
||||||
on `bg`.
|
on `bg`.
|
||||||
- Left to right: a 36px `surface-2` close circle (`x`), then "N selected"
|
- Left to right: a 36px `surface-2` close circle (`x`), then "N selected"
|
||||||
(17/500), then the bulk actions, trailing-anchored. Fixed width, never
|
(17/500), then the bulk actions, trailing-anchored. Fixed width, never
|
||||||
scrolls: only the first few actions show inline (3 on mobile, 4 on
|
scrolls: only the first few actions show inline (3 on mobile, 4 on
|
||||||
wide tablet - there can be up to 9: favorite, share, download, delete, copy,
|
desktop - there can be up to 9: favorite, share, download, delete, copy,
|
||||||
move, rename, sync, details); the rest sit behind a trailing "More"
|
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
|
button that opens the same grouped-list sheet a file row's own overflow
|
||||||
menu (`ellipsis`/`ellipsis-vertical`) already uses. Which actions land in
|
menu (`ellipsis`/`ellipsis-vertical`) already uses. Which actions land in
|
||||||
@@ -193,20 +193,20 @@ multi-select)
|
|||||||
- Mobile: inline actions are plain 20px accent-text icons, no fill; "More"
|
- Mobile: inline actions are plain 20px accent-text icons, no fill; "More"
|
||||||
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
|
is the same 40px `NooOverflowButton` treatment (`ellipsis`/
|
||||||
`ellipsis-vertical`, fg-3) file rows use for their own overflow menu.
|
`ellipsis-vertical`, fg-3) file rows use for their own overflow menu.
|
||||||
- Wide tablet: inline actions are labelled tonal pills (accent-soft/
|
- Desktop: inline actions are labelled tonal pills (accent-soft/
|
||||||
accent-text), danger-soft/danger for the one labelled "Delete" - wide tablet
|
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
|
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
|
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
|
(surface-2/fg-1) with a trailing `chevron-down`, so it reads as "opens a
|
||||||
menu" rather than another bulk action.
|
menu" rather than another bulk action.
|
||||||
|
|
||||||
**Grid card**
|
**Grid card**
|
||||||
- Surface fill, radius 18. Mobile uses 2 columns with a 10px gap; wide tablet uses 5 columns with a 16px gap.
|
- 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.
|
- 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).
|
- Below it: the name (14/500) with an overflow menu, then the meta (12).
|
||||||
|
|
||||||
**Photo grid**
|
**Photo grid**
|
||||||
- Square tiles with a 2px gap on mobile (3 columns, edge to edge) and a 4px gap on wide tablet (8 columns).
|
- 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.
|
- 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)`.
|
- Videos get a badge in the bottom-right: a `play` icon and the duration on `rgba(14,13,11,.7)`.
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ multi-select)
|
|||||||
- Time in meta style. The file tile (32) goes on the right.
|
- Time in meta style. The file tile (32) goes on the right.
|
||||||
|
|
||||||
**Banner / summary card**
|
**Banner / summary card**
|
||||||
- A surface card with radius 20 (surface-2 on wide tablet) and padding of 14–18.
|
- 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
|
- 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
|
onto the same line - a long retention message/caption and the action
|
||||||
used to fight for the same row's width.
|
used to fight for the same row's width.
|
||||||
@@ -236,7 +236,7 @@ multi-select)
|
|||||||
**Settings row**
|
**Settings row**
|
||||||
- 52px tall, or 60px with a subtitle.
|
- 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.
|
- 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 (wide tablet uses a 32px dropdown with radius 10), a segmented control, a pill button, or a status badge.
|
- 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.
|
- A destructive row uses danger text.
|
||||||
- Exception: a segmented control with 3+ labelled segments (Theme's
|
- Exception: a segmented control with 3+ labelled segments (Theme's
|
||||||
System/Light/Dark) gets its own row below the label instead of a
|
System/Light/Dark) gets its own row below the label instead of a
|
||||||
@@ -247,14 +247,14 @@ multi-select)
|
|||||||
control and Photos' filter-sheet type control already use.
|
control and Photos' filter-sheet type control already use.
|
||||||
|
|
||||||
**Tab order row**
|
**Tab order row**
|
||||||
- A grip icon (`grip-vertical`, fg-3), the tab icon, the label, then a pin button: a 36px circle (30 on wide tablet) that is accent-soft with `pin` when pinned, or surface-2 with `pin-off` when not.
|
- 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.
|
- Allow at most 5 pinned tabs.
|
||||||
|
|
||||||
**Sheet** (mobile)
|
**Sheet** (mobile)
|
||||||
- Surface fill, radius 28 at the top. A 36×5 grabber in surface-3. Padding 20, with 22px between sections.
|
- 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.
|
- It sits on a scrim.
|
||||||
|
|
||||||
**Dialog** (wide tablet)
|
**Dialog** (desktop)
|
||||||
- 540px wide, radius 24, padding 24, sitting on a scrim.
|
- 540px wide, radius 24, padding 24, sitting on a scrim.
|
||||||
- Header: file tile, title "Share "<name>"", and a close button (32px circle, surface-2).
|
- Header: file tile, title "Share "<name>"", and a close button (32px circle, surface-2).
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
|
|||||||
| Status bar | 54px, Dynamic Island | 40px |
|
| 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` |
|
| 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 | 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 wide tablet 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 - 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. |
|
| 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 |
|
| 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. |
|
| 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. |
|
||||||
@@ -291,9 +291,9 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
|
|||||||
| 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. |
|
| 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" |
|
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
|
||||||
|
|
||||||
### Wide tablet
|
### Desktop
|
||||||
|
|
||||||
Wide tablet has a 256px sidebar in `bg` and a main pane in `surface`, separated by a 1px line.
|
Desktop has a 256px sidebar in `bg` and a main pane in `surface`, separated by a 1px line.
|
||||||
|
|
||||||
**Sidebar**, top to bottom:
|
**Sidebar**, top to bottom:
|
||||||
- account switcher card
|
- account switcher card
|
||||||
@@ -312,7 +312,14 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
- the search field
|
- the search field
|
||||||
- context actions: List/Grid toggle and Upload in Files, Upload in Photos, Empty trash in Trash, Sync now in Offline
|
- 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" |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -320,7 +327,7 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
|
|
||||||
- **Files:**
|
- **Files:**
|
||||||
- Toolbar: sort chip (`Name ↑`), type filter chip, List/Grid segmented toggle.
|
- Toolbar: sort chip (`Name ↑`), type filter chip, List/Grid segmented toggle.
|
||||||
- Wide tablet adds Modified and Shared filter chips, an active-filter chip, and an item count.
|
- 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.
|
- Content: a list, or a grid when the toggle is set.
|
||||||
- **Photos:** a sort chip and a filter chip (mirroring Files' own controls
|
- **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
|
row - no List/Grid toggle, the grid is the only view). The type filter
|
||||||
@@ -335,7 +342,7 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
Files' old checkmark list or Photos' own always-labelled track. Then the
|
Files' old checkmark list or Photos' own always-labelled track. Then the
|
||||||
photo grid grouped by month.
|
photo grid grouped by month.
|
||||||
- **Favorites:** a file list with a star as the trailing icon.
|
- **Favorites:** a file list with a star as the trailing icon.
|
||||||
- **Offline:** a summary card (or 4 stat cards on wide tablet), then a list whose rows show sync status icons and status meta.
|
- **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
|
- **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
|
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
|
three scopes - tapping a row (file or folder) opens the full Share sheet
|
||||||
@@ -345,8 +352,8 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
copying a link or removing access lives, so a second, row-local menu here
|
copying a link or removing access lives, so a second, row-local menu here
|
||||||
was redundant.
|
was redundant.
|
||||||
- **Recent:** a list grouped into Today, Yesterday and This week. The meta is the action plus the location.
|
- **Recent:** a list grouped into Today, Yesterday and This week. The meta is the action plus the location.
|
||||||
- **Activity:** the feed grouped by day. Wide tablet limits it to 760px wide.
|
- **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; wide tablet rows get a tonal "Restore" pill.
|
- **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:
|
- **Settings:** sections in this order:
|
||||||
1. Account card
|
1. Account card
|
||||||
2. Accounts (list, then "Add account")
|
2. Accounts (list, then "Add account")
|
||||||
@@ -358,7 +365,7 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
8. Action bar - a reorder-only list (no pin/hide, unlike Tabs): the
|
8. Action bar - a reorder-only list (no pin/hide, unlike Tabs): the
|
||||||
priority order for the Selection action bar's bulk actions (favorite,
|
priority order for the Selection action bar's bulk actions (favorite,
|
||||||
share, download, delete, copy, move, rename, sync, details) - the
|
share, download, delete, copy, move, rename, sync, details) - the
|
||||||
first few (3 mobile / 4 wide tablet) land in the bar's fixed inline
|
first few (3 mobile / 4 desktop) land in the bar's fixed inline
|
||||||
slots, the rest sit behind "More". See §2 "Selection action bar".
|
slots, the rest sit behind "More". See §2 "Selection action bar".
|
||||||
9. Swipe on a file
|
9. Swipe on a file
|
||||||
|
|
||||||
@@ -376,7 +383,7 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
`collapsible` param) so the page could at least be collapsed down. Both
|
`collapsible` param) so the page could at least be collapsed down. Both
|
||||||
scrolled the *same* page to or past an anchor; a genuinely separate
|
scrolled the *same* page to or past an anchor; a genuinely separate
|
||||||
pushed screen per category removes the scroll-depth problem outright
|
pushed screen per category removes the scroll-depth problem outright
|
||||||
instead of just working around it. Wide tablet is unchanged: a 2-column grid
|
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
|
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.
|
without scrolling, so it gets neither a menu nor collapsing.
|
||||||
- **Share sheet / dialog:** sections in this order:
|
- **Share sheet / dialog:** sections in this order:
|
||||||
@@ -424,7 +431,7 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
(not the inset-panel text-field shape - a search field is always the pill,
|
(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,
|
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
|
autofocus, a clear button once there's a query. Below it: a file list
|
||||||
(mobile rows / wide tablet table, same as Files), or a centered icon + short
|
(mobile rows / desktop table, same as Files), or a centered icon + short
|
||||||
sentence-case message for the empty ("Search your files") and no-results
|
sentence-case message for the empty ("Search your files") and no-results
|
||||||
states.
|
states.
|
||||||
- **Upload / Move / Copy destination picker:** a pushed screen (outside the
|
- **Upload / Move / Copy destination picker:** a pushed screen (outside the
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -130,7 +130,7 @@ a:hover{color:#6E08B3}
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;flex-direction:column;gap:12px">
|
<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 / tablet sidebar</div>
|
<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">
|
<div style="display:flex;background:var(--sf);border-radius:20px;padding:10px 0">
|
||||||
<sc-for list="{{ nav }}" as="n" hint-placeholder-count="5">
|
<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.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>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ a:hover{color:#6E08B3}
|
|||||||
<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="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="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: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 and Android. One brand language, navigation adapted per platform. Light and dark shown for every screen.</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 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>
|
</div>
|
||||||
|
|
||||||
@@ -47,20 +47,44 @@ a:hover{color:#6E08B3}
|
|||||||
</section>
|
</section>
|
||||||
</sc-for>
|
</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>
|
||||||
|
</div>
|
||||||
<script type="text/x-dc" data-dc-script data-props="{"platforms":{"editor":"enum","options":["all","ios","android"],"default":"all","tsType":"string"},"themes":{"editor":"enum","options":["both","light","dark"],"default":"both","tsType":"string"}}">
|
</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 {
|
class Component extends DCLogic {
|
||||||
renderVals() {
|
renderVals() {
|
||||||
const pf = this.props.platforms || 'all';
|
const pf = this.props.platforms || 'all';
|
||||||
const th = this.props.themes || 'both';
|
const th = this.props.themes || 'both';
|
||||||
const themes = [{ id: 'light', label: 'Light' }, { id: 'dark', label: 'Dark' }].filter(t => th === 'both' || th === t.id);
|
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 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 num = a => a.map(([id, label], i) => ({ id, label, n: String(i + 1).padStart(2, '0') }));
|
||||||
const keep = id => pf === 'all' || pf === id;
|
const keep = id => pf === 'all' || pf === id;
|
||||||
return {
|
return {
|
||||||
screensM: num(M),
|
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))
|
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))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too).
|
|||||||
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||||
Flutter's own default to already be high enough.
|
Flutter's own default to already be high enough.
|
||||||
|
|
||||||
|
**The three locks are independent** (Settings -> Security;
|
||||||
|
`SessionController`): `loginLockEnabled` (unlock to open the app - the only one
|
||||||
|
`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to
|
||||||
|
switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden
|
||||||
|
files). Each gate works with the others off - `passGate(gate, reason)`
|
||||||
|
prompts whenever its own `gate` is on, whether or not login lock is. Turning
|
||||||
|
any of them on **or off** needs a successful `AppLockService.authenticate`
|
||||||
|
(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set;
|
||||||
|
off needs it so someone with a momentarily unlocked phone can't remove it),
|
||||||
|
and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle
|
||||||
|
dependency is gone. Tests replace the prompt through
|
||||||
|
`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`.
|
||||||
|
|
||||||
`isRestoringSession` still gates the splash screen until the above resolves
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— see `standards.md` for why widget tests must mock both storage channels
|
||||||
rather than relying on this async path throwing naturally.
|
rather than relying on this async path throwing naturally.
|
||||||
@@ -811,3 +824,243 @@ rather than relying on this async path throwing naturally.
|
|||||||
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||||
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||||
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||||
|
|
||||||
|
## Native services on iOS (in progress)
|
||||||
|
|
||||||
|
The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
|
||||||
|
`upload_service`, `download_service`, `sync_service`, plus their status
|
||||||
|
`EventChannel`s) were written for Android in Kotlin. iOS implements
|
||||||
|
`upload_service` (+ its status stream), `download_service` and
|
||||||
|
`share_intent` and `sync_service` so far (`ios/Runner/Native/` +
|
||||||
|
`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so
|
||||||
|
calls throw `MissingPluginException`. `lib/services/native_channel.dart`
|
||||||
|
makes that safe until each one is built in Swift:
|
||||||
|
|
||||||
|
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
|
||||||
|
checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and
|
||||||
|
scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`,
|
||||||
|
`finishPick`/`cancelPick`).
|
||||||
|
- `invokeOrExplain` - throws `NativeServiceUnavailable("<Feature>")`, whose
|
||||||
|
message callers already show in a snackbar ("Uploading isn't available on
|
||||||
|
this platform yet."). For things the user just asked for: `startUpload`,
|
||||||
|
`startDownload`, `syncNow`, `resolveConflict`.
|
||||||
|
- `quietEvents` - wraps an `EventChannel` so the missing-implementation error
|
||||||
|
is dropped instead of surfacing as an unhandled stream error.
|
||||||
|
|
||||||
|
When a channel gets a real iOS implementation nothing changes on the Dart
|
||||||
|
side - the helpers only act on `MissingPluginException`.
|
||||||
|
|
||||||
|
`SyncService.baseDirectory()` is where the `sync/<accountId>/...` mirror
|
||||||
|
lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt`
|
||||||
|
writes), the app support directory elsewhere, because that call throws on
|
||||||
|
iOS. `OfflineController` and `localSyncedFilePath` both go through it.
|
||||||
|
### iOS upload/download (`ios/Runner/Native/`)
|
||||||
|
|
||||||
|
- `NativeServices.swift` registers the channels from
|
||||||
|
`AppDelegate.didInitializeImplicitFlutterEngine` (via
|
||||||
|
`engineBridge.applicationRegistrar.messenger()`) and forwards
|
||||||
|
`startUpload`/`startDownload` to `TransferManager`; a batch's completion
|
||||||
|
goes back over `upload_service/status` so `FilesController` refreshes the
|
||||||
|
folder like on Android.
|
||||||
|
- `TransferManager.swift` runs everything on one *background* `URLSession`
|
||||||
|
(`dev.ayushya.noo.transfers`, `sessionSendsLaunchEvents`), so transfers
|
||||||
|
finish after the app is suspended/closed; `AppDelegate` hands the system's
|
||||||
|
`handleEventsForBackgroundURLSession` completion handler to it and calls
|
||||||
|
`reconnect()` at launch. Each task carries its info in `taskDescription`
|
||||||
|
and its batch's running totals live in `UserDefaults`, so the one summary
|
||||||
|
local notification ("Uploaded 3 files") still fires after a relaunch.
|
||||||
|
- Uploads: one `PUT` per file to `<server>/remote.php/dav/files/<user>/...`
|
||||||
|
from a staged copy in Caches (no chunking, so very large files are bound by
|
||||||
|
the server's single-request limit; an existing file with the same name is
|
||||||
|
overwritten). Sends `X-OC-Mtime`. Downloads: `GET` into
|
||||||
|
`Documents/Downloads`, with `UIFileSharingEnabled` +
|
||||||
|
`LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under
|
||||||
|
"On My iPhone > Noo"; a name clash becomes `a (1).txt`.
|
||||||
|
- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3
|
||||||
|
files..." when a batch starts, replaced in place (same request id =
|
||||||
|
`transfer.<batch>`) by "Uploaded 3 files" when it ends. iOS can't show live
|
||||||
|
byte progress from a background session the way Android's foreground
|
||||||
|
service does (a Live Activity would, but needs a widget extension), and
|
||||||
|
there's no cancel yet. The Dart snackbar text still says "see the
|
||||||
|
notification for progress".
|
||||||
|
- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and
|
||||||
|
covered by `ios/RunnerTests` (`xcodebuild test -workspace
|
||||||
|
ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS
|
||||||
|
Simulator,id=<udid>'`).
|
||||||
|
|
||||||
|
### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`)
|
||||||
|
|
||||||
|
"Share to Noo" from any app's share sheet. A Share Extension can't open its
|
||||||
|
host app (`extensionContext.open` is Today-widget-only), so - like
|
||||||
|
Reminders/Notes - the destination is picked *inside* the sheet:
|
||||||
|
|
||||||
|
1. `ShareViewController` copies each shared file (`NSItemProvider.
|
||||||
|
loadFileRepresentation`, public.item) into the App Group container
|
||||||
|
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
|
||||||
|
`SharePickerView` (`ShareModel` holds its state).
|
||||||
|
2. It reads the accounts from the shared Keychain (`SharedAccountStore` ->
|
||||||
|
`SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND`
|
||||||
|
Depth 1, parsed by `DavFolderParser`). With **several accounts an account
|
||||||
|
list comes first** (the app's active one is marked "Active"; "Change
|
||||||
|
account" returns to it); with one it goes straight to its folders. The
|
||||||
|
Dart side keeps the keychain data current: `ShareAccountSync`
|
||||||
|
(`lib/services/share_account_service.dart`, started from the shell's
|
||||||
|
`initState`) republishes - through the native `share_account` channel's
|
||||||
|
`setAccounts` - when an account becomes ready or when its change
|
||||||
|
signature moves: the account list/active account, the three lock
|
||||||
|
settings, or the active account's hidden-files filter / storage scope;
|
||||||
|
sign-out calls
|
||||||
|
`clearAccount`. It publishes every saved account that has a stored
|
||||||
|
password and isn't signed out, each with its **own Files hidden-files
|
||||||
|
filter** (`FilesController.savedHiddenFilter`).
|
||||||
|
- **Toggles**: the folder list has two menu chips, **Hidden** (hide / only
|
||||||
|
hidden / all) and **Storage** (cloud only / only external / all +
|
||||||
|
external), each starting at the chosen account's own app setting
|
||||||
|
(`hiddenFilter`, `storageScope` published per account) and changeable for
|
||||||
|
this share only - nothing is written back. The list is fetched once per
|
||||||
|
folder (`DavClient.listFolders` returns everything, hidden and external
|
||||||
|
included, with `nc:mount-type` requested) and filtered in memory
|
||||||
|
(`ShareModel.visibleFolders`), so flipping a toggle just returns to the
|
||||||
|
top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when
|
||||||
|
it *or any ancestor* starts with a dot. External storage: only a mount's
|
||||||
|
*root* has `mount-type=external`, so `ShareModel` remembers the roots it
|
||||||
|
has seen and treats anything under one as external too (the app's own
|
||||||
|
filter doesn't, which empties its list inside a mount). "All" lists
|
||||||
|
external folders in their own "External storage" group.
|
||||||
|
- **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening
|
||||||
|
the sheet with the account's filter already not `hide`, asks for
|
||||||
|
Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the
|
||||||
|
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
|
||||||
|
like `local_auth` with `biometricOnly: false`); cancelling keeps them
|
||||||
|
hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never
|
||||||
|
asks.
|
||||||
|
- **Account switching**: choosing any account other than the app's
|
||||||
|
active one asks for the same unlock when "lock account switching" is on.
|
||||||
|
**Every gated action prompts every time**, exactly like the app - no
|
||||||
|
unlock is remembered for the sheet (an earlier version cached one, so
|
||||||
|
later account picks / hidden toggles skipped Face ID). The only
|
||||||
|
merging is within one action: picking another account whose own filter
|
||||||
|
already shows hidden folders is a single prompt covering both
|
||||||
|
(`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested).
|
||||||
|
Declining the account unlock cancels the pick; declining only the
|
||||||
|
hidden unlock shows the account with hidden folders hidden.
|
||||||
|
- Opening the sheet itself is not gated - only these two actions are
|
||||||
|
(as in the app, where login lock guards launch and these are separate
|
||||||
|
locks).
|
||||||
|
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
||||||
|
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
||||||
|
`sharedContainerIdentifier`) that outlives the extension, and posts
|
||||||
|
"Uploading N files...". Results go to whichever process is alive:
|
||||||
|
files that finish while the sheet is still open are counted by the
|
||||||
|
extension's `ShareUploadDelegate`; the rest by the app, which recreates
|
||||||
|
that session at launch (`TransferManager.reconnect`) and is relaunched by
|
||||||
|
the system. Either way `UploadResults.handle` deletes the staged copy,
|
||||||
|
counts the file in `TransferBatchStore` (batch totals live in the App
|
||||||
|
Group's `UserDefaults` because the two are different processes) and
|
||||||
|
posts the "Uploaded N files" summary on the last one; an upload the
|
||||||
|
extension finished leaves a note (`noteFinishedUpload`) so the app
|
||||||
|
refreshes that folder on its next activation. **Don't give the
|
||||||
|
extension's session no delegate** - a fast upload then finishes unseen and
|
||||||
|
the invalidated session is discarded before the app can ever hear of it
|
||||||
|
(found the hard way: no notification, no count).
|
||||||
|
4. Fallbacks: with no account (or the user taps "Choose a folder later in
|
||||||
|
Noo") the files go into `SharedInbox/pending.json` instead and a local
|
||||||
|
notification asks the user to open Noo; the app consumes that through
|
||||||
|
`share_intent` (`getInitialShare` at cold start, `share_intent/new` on
|
||||||
|
`UIApplication.didBecomeActiveNotification`) into the same Dart
|
||||||
|
`ShareUploadView` flow as an Android share, and `TransferManager.
|
||||||
|
startUpload` deletes the inbox copy once staged.
|
||||||
|
|
||||||
|
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
|
||||||
|
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
|
||||||
|
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
|
||||||
|
two processes always agree on the team-prefixed id. Everything is stored as
|
||||||
|
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
|
||||||
|
|
||||||
|
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
|
||||||
|
`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that
|
||||||
|
isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is
|
||||||
|
`dev.ayushya.noo.ShareExtension` and its version/build come from the same
|
||||||
|
`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a
|
||||||
|
mismatch); it's embedded by an "Embed Foundation Extensions" phase placed
|
||||||
|
*before* Flutter's script phases (after them Xcode reports a dependency
|
||||||
|
cycle). The simulator honours the App Group and keychain group without a
|
||||||
|
signing team; a real device needs a team that owns both ids.
|
||||||
|
|
||||||
|
### iOS device sync (`ios/Runner/Native/SyncEngine/`)
|
||||||
|
|
||||||
|
A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/
|
||||||
|
`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service`
|
||||||
|
channel (+ `/status` event stream) so the Dart side is unchanged apart from
|
||||||
|
`cancelAccount`'s `forget` flag (below). Same rules: only paths the user
|
||||||
|
synced are mirrored; brand-new local files are never uploaded (only edits to
|
||||||
|
already-synced files are); a file edited on both sides is a conflict (neither
|
||||||
|
copy is touched, "Keep local"/"Use server" resolves it); a local deletion is
|
||||||
|
repaired by re-downloading; the mirror lives at
|
||||||
|
`<Application Support>/sync/<accountId>/...` (what Dart's
|
||||||
|
`SyncService.baseDirectory()` reads for the Offline tab, excluded from
|
||||||
|
backup) with state in `<...>/sync-state/<accountId>/*.json`.
|
||||||
|
|
||||||
|
- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient`
|
||||||
|
(PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one
|
||||||
|
account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator`
|
||||||
|
(runs, `BGTaskScheduler`, notifications), and the `registerSync` channel
|
||||||
|
handlers in `NativeServices.swift`.
|
||||||
|
- **Safeguards (each has a regression test in `SyncEngineTests`, which drives
|
||||||
|
the real runner against an in-memory fake Nextcloud):** an unreachable
|
||||||
|
server, a non-207, or a PROPFIND answer that is truncated, not a
|
||||||
|
`multistatus`, empty, or has an href outside the account root / with dot
|
||||||
|
components throws `SyncRemoteUnavailable` and the path is skipped - never
|
||||||
|
diffed as empty or partial (which would delete local files); unreadable
|
||||||
|
recorded state makes the run do nothing (`SyncStateUnreadable`) instead of
|
||||||
|
treating every file as untracked and overwriting local edits; every
|
||||||
|
server-supplied path is resolved through `LocalFS.resolve` (no escaping the
|
||||||
|
mirror) and entries that would land on the same local file (case or Unicode
|
||||||
|
spellings) are skipped, not downloaded over each other; downloads go to a
|
||||||
|
temp file and replace atomically, never replacing a folder; empty-folder
|
||||||
|
pruning uses `rmdir` and only on a successful empty read; a root's etag
|
||||||
|
shortcut marker is only written when the path synced cleanly *and* the state
|
||||||
|
was saved.
|
||||||
|
- **Server deletions:** remove the local copy even if edited locally, matching
|
||||||
|
Android.
|
||||||
|
- **Deliberate differences from Android:** after our own upload the file's
|
||||||
|
etag is re-read so it isn't downloaded back; state is flushed every 20 transfers; conflicts are kept
|
||||||
|
per account on the status bus; downloads write to a temp file first. No
|
||||||
|
ongoing progress notification (a run posts a summary, and conflict
|
||||||
|
notifications with Keep local / Use server actions).
|
||||||
|
- **Running:** every run - "Sync now", the in-app refresh, and background -
|
||||||
|
goes through `SyncCoordinator.startRun`: one tracked run per account
|
||||||
|
(`force` supersedes it, a quiet check leaves it alone), serialised by one
|
||||||
|
cancellable `AsyncMutex`, tagged with a per-account *generation* so a run
|
||||||
|
that was only queued never starts for an account that was cancelled or had a
|
||||||
|
path removed meanwhile. `removeLocalSync` cancels the run and takes the same
|
||||||
|
mutex, so a finishing run can't write stale state back over it.
|
||||||
|
- **Background:** `BGAppRefreshTask` + `BGProcessingTask`
|
||||||
|
(`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch +
|
||||||
|
processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers
|
||||||
|
registered before launch finishes). `intervalMinutes` (floored at 15, like
|
||||||
|
Android) is only the *earliest* start - iOS picks when. The Files cache
|
||||||
|
settings explain this on iOS; the 15-minute Android warning appears only
|
||||||
|
on Android for intervals below 15 minutes. Wi-Fi-only is
|
||||||
|
checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require
|
||||||
|
it. An expiring task cancels its runs (they save state as they go) and is
|
||||||
|
completed exactly once (`OnceGate`). **Not testable in the simulator**
|
||||||
|
(needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier`
|
||||||
|
debugger command).
|
||||||
|
- **Simulator verification:** a temporary debug control ran the native
|
||||||
|
background slot and confirmed files update for a non-active saved account.
|
||||||
|
The control and its channel handler were removed after verification.
|
||||||
|
`startBackgroundSlot` retains a regression test for periodic eligibility.
|
||||||
|
OS wake-up delivery and background execution time limits remain unverified.
|
||||||
|
- **Credentials:** `SyncConfigStore` keeps each account's server, auth header,
|
||||||
|
paths, interval and notify flag as one Keychain item
|
||||||
|
(`AfterFirstUnlock`, so a background run works with the phone locked).
|
||||||
|
Conflict notification actions look the account up there - nothing secret is
|
||||||
|
in a notification. `sync_service.cancel` takes `forget` (default true:
|
||||||
|
signed out/removed -> credentials dropped; false: only background sync was
|
||||||
|
turned off -> kept for manual runs and conflict actions). Dart sends
|
||||||
|
`forget: false` only for "paths configured but no background interval".
|
||||||
|
- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on
|
||||||
|
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
|
||||||
|
|
||||||
|
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
|
||||||
|
|||||||
@@ -24,11 +24,18 @@ widgets. Key points:
|
|||||||
|
|
||||||
- **Material 3**, seed-color based. `AppTheme.seedColors` is the picker list
|
- **Material 3**, seed-color based. `AppTheme.seedColors` is the picker list
|
||||||
users choose from; `defaultNextcloudBlue` (`#0082C9`) is the fallback.
|
users choose from; `defaultNextcloudBlue` (`#0082C9`) is the fallback.
|
||||||
- **Dynamic color** (Android 12+ Material You) is
|
- **Dynamic color** (Android 12+ Material You / desktop accent color) is
|
||||||
supported via `package:dynamic_color`'s `DynamicColorBuilder` wrapping the
|
supported via `package:dynamic_color`'s `DynamicColorBuilder` wrapping the
|
||||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||||
|
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
|
||||||
|
`surface` (cards, selection bar, rows) from the container roles so it
|
||||||
|
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
|
||||||
|
`surfaceContainer` in dark. The dynamic_color package seeds the container
|
||||||
|
roles from the primary color rather than the OS palette, and in light mode
|
||||||
|
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
|
||||||
|
made cards blend into the page ("the selection bar has no background").
|
||||||
- **Font**: Inter via `google_fonts`, applied through
|
- **Font**: Inter via `google_fonts`, applied through
|
||||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
@@ -79,9 +86,9 @@ Catalog:
|
|||||||
|
|
||||||
| Folder | Components |
|
| Folder | Components |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (wide tablet), `NooSwipeAction` |
|
| `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` |
|
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
|
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
|
||||||
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
||||||
@@ -102,6 +109,8 @@ Gotchas:
|
|||||||
trigger it) but also fires it directly if the drag goes far enough (past
|
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
|
1.8x the block's width) before release - a full swipe-through does delete
|
||||||
in one gesture, it's not tap-only anymore.
|
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
|
- Always read colors through `context.nooColors`, never
|
||||||
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
|
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
|
||||||
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
|
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
|
||||||
@@ -114,7 +123,8 @@ Gotchas:
|
|||||||
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
||||||
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
||||||
track blends invisibly into the sheet instead of reading as a grouped
|
track blends invisibly into the sheet instead of reading as a grouped
|
||||||
control (Files'/Photos' sort and type-filter sheets do this).
|
control (Files'/Photos' filter sheets and Settings controls on surface
|
||||||
|
rows use the same `surface2` track).
|
||||||
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
||||||
on its scrollable child, or pull-to-refresh silently can't be triggered
|
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) -
|
once the list is short enough to fit the viewport (empty, or one item) -
|
||||||
@@ -133,6 +143,32 @@ blocks are noted where they matter:
|
|||||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||||
Offline), and reused as-is by Favorites.
|
Offline), and reused as-is by Favorites.
|
||||||
|
- Selection action bars in Files, Photos, and Favorites have a global
|
||||||
|
Settings → Action bar → Position choice: Top (default) or Bottom,
|
||||||
|
persisted as `ui_selection_bar_position`. Top stays pinned in the content
|
||||||
|
header; Bottom uses `SelectionBarOverlay` above navigation (or the content
|
||||||
|
pane's bottom on desktop), with extra list clearance. The overlay uses
|
||||||
|
Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation
|
||||||
|
and safe areas are accounted for without counting their height twice.
|
||||||
|
The shell hides Android's Upload FAB during selection via view callbacks.
|
||||||
|
The mobile Action bar settings use one grouped card for Position and
|
||||||
|
the reorder list, with no nested rounded corners or repeated page title.
|
||||||
|
Their header backdrop is transparent, so content scrolls behind the inset
|
||||||
|
`surface` card. A `line` border and `nooDialogShadow` match the floating
|
||||||
|
bottom bar's solid treatment.
|
||||||
|
- Hidden files and external storage are each a three-way segmented row
|
||||||
|
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||||
|
in both the Files and Photos filter sheets), not toggles:
|
||||||
|
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
|
||||||
|
cloud (default) / external / all. Files and Photos each persist their own
|
||||||
|
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||||
|
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||||
|
`StorageScope` stays shared. Photos uses the same segmented pill for
|
||||||
|
Favorites (All / Favorites only). Leaving `hide` is still behind the
|
||||||
|
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
|
||||||
|
regular items first, then a collapsible "External storage" section
|
||||||
|
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
||||||
|
holding the external ones; Photos/Favorites just merge them into one list.
|
||||||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||||
trail Files uses in place of the shared
|
trail Files uses in place of the shared
|
||||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||||
@@ -154,7 +190,7 @@ blocks are noted where they matter:
|
|||||||
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
||||||
`lib/widgets/shell/shell_common.dart` — account/storage formatting,
|
`lib/widgets/shell/shell_common.dart` — account/storage formatting,
|
||||||
`openSettings`/`openSearch`, `showAccountSwitcher`, `ShellAvatarButton`,
|
`openSettings`/`openSearch`, `showAccountSwitcher`, `ShellAvatarButton`,
|
||||||
`ShellSearchLauncher`, shared by the mobile and wide tablet shell chrome.
|
`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)
|
||||||
— `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
|
— `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
|
||||||
rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
|
rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
|
||||||
@@ -227,9 +263,27 @@ blocks are noted where they matter:
|
|||||||
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||||||
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||||||
don't confuse the two: this one is still non-blurred, just inset with a
|
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
|
`line` border and `nooDialogShadow` (also used by floating selection bars - see
|
||||||
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
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
|
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||||
|
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||||
|
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||||
|
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||||
|
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||||
|
styles) swaps the bar's solid `surface` for a translucent one over a
|
||||||
|
`BackdropFilter` blur and drops
|
||||||
|
`nooDialogShadow` (it would show through the glass). Strength is user-set:
|
||||||
|
`FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the
|
||||||
|
old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity
|
||||||
|
(0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown
|
||||||
|
only while frosted glass is on; both persist
|
||||||
|
(`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as
|
||||||
|
`NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once
|
||||||
|
the sliders are moved off all three. A frosted bar only
|
||||||
|
reads as glass with content behind it, so the shell uses
|
||||||
|
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||||
|
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||||
|
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
|
||||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||||
which also means every tab's own scrollable list has to reserve enough
|
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
|
bottom padding to clear the bar - nothing does that automatically once the
|
||||||
@@ -242,44 +296,50 @@ blocks are noted where they matter:
|
|||||||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||||
anywhere that needs the *current* cap on regular tabs.
|
anywhere that needs the *current* cap on regular tabs.
|
||||||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
|
||||||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
|
||||||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
a persisted Avatar position choice (`ui_avatar_position`, Top by default).
|
||||||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
`AvatarNavigationHost` owns one 420ms open / 320ms close animation using
|
||||||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
`easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
|
||||||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
its controls fade in after the expansion starts. Top moves the content pane
|
||||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
down through `AvatarNavigationBody`, keeping bottom navigation stationary.
|
||||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
Bottom moves the avatar upward into the card's bottom-right corner and
|
||||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
overlays the list immediately above navigation. Both use the Files gutter.
|
||||||
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
The floating rail beside a bottom avatar shows text only on the selected
|
||||||
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
|
||||||
the drawer. The menu header has a chevron that expands an account section
|
with the avatar inside the trailing end. Its popup expands upward with
|
||||||
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
full screen width, rounded top corners and no gap above the bar; the
|
||||||
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
avatar stays in the bar, and Search keeps its inline position.
|
||||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
The attached popup has no outer border or shadow, blending into the bar.
|
||||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
All bottom popups order navigation (including Settings), current account,
|
||||||
not a modal flow) - there's no existing anchored-popup primitive here
|
then expanded secondary accounts and Add/Manage Account controls.
|
||||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
The iOS shell places Upload and the top avatar on the large-title row.
|
||||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
|
||||||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
|
||||||
inset, not the top bar's full height on top of that) so it covers the
|
shows the plus-and-Upload button. Upload uses the folder icon's tonal
|
||||||
top bar - including the tab title - rather than sitting below it, and
|
`accentSoft` background and `accentText` foreground. Search stays below.
|
||||||
its card carries two stacked `boxShadow`s rather than just
|
Upload uses the compact 32px size to match the visible top avatar.
|
||||||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
On iOS the accent picker hides the unsupported wallpaper color option;
|
||||||
to read as basically invisible on a small card over a dark theme's
|
the accent subtitle, swatch and selection always reflect the seed color.
|
||||||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
|
||||||
edge to be visible against an already-dark backdrop), so a second,
|
A shared shell animation keeps separately mounted tab headers in sync
|
||||||
tighter, more opaque contact shadow underneath it gives real elevation
|
during Auto tab changes; reduced motion skips the transition.
|
||||||
in both themes. The card's border can go missing wherever an opaque row
|
Avatar navigation and its bottom satellite share the bottom bar's frost
|
||||||
sits against it too (every corner but the header's, which has no
|
toggle, blur and opacity, including throughout the popup animation.
|
||||||
full-bleed fill of its own) if a `Container` combines `border` with its
|
The setting is labelled "Frosted glass" and also controls both media
|
||||||
own `clipBehavior` - that paints the border as part of the *outer*
|
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
|
||||||
decoration, then the clipped child on top right up to the same boundary,
|
without a backdrop filter; On uses the shared blur and tint opacity.
|
||||||
with no gap for the border's own stroke to show through. Fixed the same
|
Search starts inside the rail when a bottom avatar occupies the satellite;
|
||||||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
its row slot smoothly collapses as Search fades into that satellite while
|
||||||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
the avatar moves into the popup. Closing reverses the same path. The card
|
||||||
reduced by that same 1px) around the filled, clipped content instead, so
|
retains hidden tabs, Settings, account switching, Add Account, and Manage
|
||||||
it never paints over the border. Also added
|
Accounts. Account controls scroll on short screens. Tap outside, tap the
|
||||||
|
popup avatar, or system Back to close. Reduced motion opens/closes without
|
||||||
|
the transition. Drawer gestures are disabled in avatar mode. Standalone
|
||||||
|
top bars outside the shell retain `showGeneralDialog` as a fallback.
|
||||||
|
`ShellAvatarButton` keeps its vertical-swipe account shortcuts in either
|
||||||
|
position. Its onTap override must have a matching tooltip/semantics label.
|
||||||
|
Also added
|
||||||
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||||||
second search row under the way iOS's `search:` slot does, so an inline
|
second search row under the way iOS's `search:` slot does, so an inline
|
||||||
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||||||
@@ -309,11 +369,13 @@ blocks are noted where they matter:
|
|||||||
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
`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 non-iOS video player's seek bar, plus a perpetually-animated
|
||||||
`SeekBarPreview` wrapper used by the Settings style picker so every
|
`SeekBarPreview` wrapper used by the Settings style picker so every
|
||||||
preview always matches the real widget exactly (same painter, just fed
|
preview always matches the real widget exactly (same painter, just fed
|
||||||
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
||||||
forking the painter.
|
forking the painter.
|
||||||
|
iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style
|
||||||
|
picker. Scrubbing pauses playback temporarily and restores its prior state.
|
||||||
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
||||||
back button + filename, the bottom action bar, the video transport
|
back button + filename, the bottom action bar, the video transport
|
||||||
controls) all share one small hand-rolled icon-button pattern
|
controls) all share one small hand-rolled icon-button pattern
|
||||||
@@ -352,21 +414,21 @@ blocks are noted where they matter:
|
|||||||
|
|
||||||
## Text/markdown viewer
|
## Text/markdown viewer
|
||||||
|
|
||||||
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; every text file opens read-only (plain text as-is, Markdown rendered) and follows Edit -> Save: an Edit/Preview toggle and, once dirty, a Save button live in the viewer's top bar as round icon-only `NooButton`s (`iconOnly`, 36px; a lone button is primary, with two Edit/Preview is secondary and Save primary; Save becomes a primary circle with a spinner while it saves). Offline copies are read-only with no buttons. The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||||
|
|
||||||
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
||||||
|
|
||||||
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with the same Edit/Preview toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||||
|
|
||||||
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||||
|
|
||||||
## Wide tablet layout notes
|
## Desktop/tablet layout notes
|
||||||
|
|
||||||
- The wide tablet branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
||||||
system bars clear of the sidebar; the sidebar has no divider between pinned
|
system bars clear of the sidebar; the sidebar has no divider between pinned
|
||||||
and hidden tabs.
|
and hidden tabs.
|
||||||
- Wide tablet Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
||||||
pushed category screens render the wide tablet card layout.
|
pushed category screens render the desktop card layout.
|
||||||
- Settings that don't apply in wide-tablet (Appearance nav options,
|
- Settings that don't apply in wide-tablet (Appearance nav options,
|
||||||
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
||||||
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Website: <https://noo.ayushya.dev>
|
|||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
|
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
|
||||||
- An Android device or emulator, or an iOS device or simulator
|
- An Android device or emulator, an iOS device, or Windows
|
||||||
- A Nextcloud server with Login Flow v2 enabled (on by default since
|
- A Nextcloud server with Login Flow v2 enabled (on by default since
|
||||||
Nextcloud 15)
|
Nextcloud 15)
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ finish signing in.
|
|||||||
```bash
|
```bash
|
||||||
flutter build apk # Android (debug-signed unless you configure a key)
|
flutter build apk # Android (debug-signed unless you configure a key)
|
||||||
flutter build ios # iOS
|
flutter build ios # iOS
|
||||||
|
flutter build windows # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
Release builds read signing details from `android/key.properties` or from
|
Release builds read signing details from `android/key.properties` or from
|
||||||
|
|||||||
@@ -7,15 +7,44 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 308A73EC19D196226AB395DC /* DavSyncClient.swift */; };
|
||||||
|
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
|
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */; };
|
||||||
|
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */; };
|
||||||
|
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
|
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||||
|
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||||
|
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||||
|
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||||
|
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A39E02713460B6C7875F3278 /* SyncStore.swift */; };
|
||||||
|
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C028B2E4E2874BB74498EABD /* SyncRunner.swift */; };
|
||||||
|
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 901C0CDB4FB819302E2A7563 /* NativeServices.swift */; };
|
||||||
|
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */; };
|
||||||
|
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */; };
|
||||||
|
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */; };
|
||||||
|
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||||
|
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */; };
|
||||||
|
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||||
|
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */; };
|
||||||
|
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */; };
|
||||||
|
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AE6F681A3100A461B4A5318 /* Foundation.framework */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */; };
|
||||||
|
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51604DEE43E937A5FDCA7A58 /* DavFolders.swift */; };
|
||||||
|
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18691F7A45AD55A2246075DA /* SyncConfigStore.swift */; };
|
||||||
|
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F2C956C98F58A97B5595307 /* SharedAccount.swift */; };
|
||||||
|
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = 871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */; };
|
||||||
|
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */; };
|
||||||
|
FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8064CC1652B8F0A44E371638 /* TransferManager.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -26,9 +55,27 @@
|
|||||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||||
remoteInfo = Runner;
|
remoteInfo = Runner;
|
||||||
};
|
};
|
||||||
|
850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = F9BE9B2F18B10324D7B84776;
|
||||||
|
remoteInfo = ShareExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
223784E5DAE5A653F0F61771 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -42,26 +89,59 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncDiff.swift; sourceTree = "<group>"; };
|
||||||
|
0AE6F681A3100A461B4A5318 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
|
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncConfigStore.swift; sourceTree = "<group>"; };
|
||||||
|
1F2C956C98F58A97B5595307 /* SharedAccount.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedAccount.swift; sourceTree = "<group>"; };
|
||||||
|
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferTypes.swift; sourceTree = "<group>"; };
|
||||||
|
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
|
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncEngineTests.swift; sourceTree = "<group>"; };
|
||||||
|
308A73EC19D196226AB395DC /* DavSyncClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavSyncClient.swift; sourceTree = "<group>"; };
|
||||||
|
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferNotifications.swift; sourceTree = "<group>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
405A5408F4071B4D12F08B21 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
49213989F0FA1B83B0B8A523 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DavFolders.swift; sourceTree = "<group>"; };
|
||||||
|
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharePickerView.swift; sourceTree = "<group>"; };
|
||||||
|
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
8064CC1652B8F0A44E371638 /* TransferManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransferManager.swift; sourceTree = "<group>"; };
|
||||||
|
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SharedInbox.swift; sourceTree = "<group>"; };
|
||||||
|
857A0E7637776A71AFBB0614 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
|
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebDAV.swift; sourceTree = "<group>"; };
|
||||||
|
901C0CDB4FB819302E2A7563 /* NativeServices.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NativeServices.swift; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
|
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceAuth.swift; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A39E02713460B6C7875F3278 /* SyncStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncStore.swift; sourceTree = "<group>"; };
|
||||||
|
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncModels.swift; sourceTree = "<group>"; };
|
||||||
|
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareUpload.swift; sourceTree = "<group>"; };
|
||||||
|
C028B2E4E2874BB74498EABD /* SyncRunner.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SyncRunner.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
807C81744907462A25A6C1F8 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
91C1658DC7467CACB2AAC7FF /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -73,14 +153,62 @@
|
|||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
2B7F16B0F0EA2C5A842ED860 /* iOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
0AE6F681A3100A461B4A5318 /* Foundation.framework */,
|
||||||
|
);
|
||||||
|
name = iOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||||
|
2947D6E441BA30E10B1F8C17 /* SyncEngineTests.swift */,
|
||||||
);
|
);
|
||||||
path = RunnerTests;
|
path = RunnerTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
3664F08C433ECEB00D1C4BDB /* ShareExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
27BDF61F5197F9B8F648E8AE /* ShareViewController.swift */,
|
||||||
|
49213989F0FA1B83B0B8A523 /* Info.plist */,
|
||||||
|
65F57A1EB0C62774E561860D /* ShareExtension.entitlements */,
|
||||||
|
5BE7B8C31FF78E212BD71DFC /* SharePickerView.swift */,
|
||||||
|
97ACCF43E1EC13AA2538E42C /* DeviceAuth.swift */,
|
||||||
|
);
|
||||||
|
name = ShareExtension;
|
||||||
|
path = ShareExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
5055E53DC55C0BCABD5F04F4 /* Native */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
8064CC1652B8F0A44E371638 /* TransferManager.swift */,
|
||||||
|
901C0CDB4FB819302E2A7563 /* NativeServices.swift */,
|
||||||
|
DDC89BA8A42212F55851CBA1 /* SyncEngine */,
|
||||||
|
);
|
||||||
|
name = Native;
|
||||||
|
path = Native;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
5DF0A9032A8500B67D6C89AE /* Shared */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
836D37F2376AFCE0EFDD30A7 /* SharedInbox.swift */,
|
||||||
|
871EDB5791D5B3DA0ECF3D4E /* WebDAV.swift */,
|
||||||
|
20B0C3085FA6467C1A4A2947 /* TransferTypes.swift */,
|
||||||
|
1F2C956C98F58A97B5595307 /* SharedAccount.swift */,
|
||||||
|
B93DB1F595F44E2F2057BB2A /* ShareUpload.swift */,
|
||||||
|
51604DEE43E937A5FDCA7A58 /* DavFolders.swift */,
|
||||||
|
3247AAA4B65FE0F1A7279FA1 /* TransferNotifications.swift */,
|
||||||
|
);
|
||||||
|
name = Shared;
|
||||||
|
path = Shared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -100,6 +228,9 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
5DF0A9032A8500B67D6C89AE /* Shared */,
|
||||||
|
3664F08C433ECEB00D1C4BDB /* ShareExtension */,
|
||||||
|
A5B80AB7F4AB17F90B745D0D /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -108,6 +239,7 @@
|
|||||||
children = (
|
children = (
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||||
|
405A5408F4071B4D12F08B21 /* ShareExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -124,10 +256,35 @@
|
|||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
5055E53DC55C0BCABD5F04F4 /* Native */,
|
||||||
|
857A0E7637776A71AFBB0614 /* Runner.entitlements */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A5B80AB7F4AB17F90B745D0D /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2B7F16B0F0EA2C5A842ED860 /* iOS */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
DDC89BA8A42212F55851CBA1 /* SyncEngine */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AF9CFAFB2387EB4E34A992BC /* SyncModels.swift */,
|
||||||
|
A39E02713460B6C7875F3278 /* SyncStore.swift */,
|
||||||
|
308A73EC19D196226AB395DC /* DavSyncClient.swift */,
|
||||||
|
0933A96CE0B65A9A6AB2D098 /* SyncDiff.swift */,
|
||||||
|
C028B2E4E2874BB74498EABD /* SyncRunner.swift */,
|
||||||
|
18691F7A45AD55A2246075DA /* SyncConfigStore.swift */,
|
||||||
|
57FF20A8DFA5A1A9F75A5F5A /* SyncCoordinator.swift */,
|
||||||
|
);
|
||||||
|
name = SyncEngine;
|
||||||
|
path = SyncEngine;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -152,6 +309,7 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
201269C4EFA9E5FDBC74F86F /* Embed Foundation Extensions */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
@@ -162,6 +320,7 @@
|
|||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = Runner;
|
name = Runner;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -171,6 +330,23 @@
|
|||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
F9BE9B2F18B10324D7B84776 /* ShareExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
3CD40C8747E650E6F817CDDB /* Sources */,
|
||||||
|
807C81744907462A25A6C1F8 /* Frameworks */,
|
||||||
|
4EDBC7D61EA0C0974DA27BD6 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = ShareExtension;
|
||||||
|
productName = ShareExtension;
|
||||||
|
productReference = 405A5408F4071B4D12F08B21 /* ShareExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -209,6 +385,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
97C146ED1CF9000F007C117D /* Runner */,
|
97C146ED1CF9000F007C117D /* Runner */,
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||||
|
F9BE9B2F18B10324D7B84776 /* ShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -221,6 +398,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
4EDBC7D61EA0C0974DA27BD6 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -274,6 +458,24 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||||
|
804B3C9878B9A8BB30E09340 /* SyncEngineTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
3CD40C8747E650E6F817CDDB /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
63229CACE4281880037D33E6 /* ShareViewController.swift in Sources */,
|
||||||
|
33B3C3BED4C1F26439DD6825 /* SharedInbox.swift in Sources */,
|
||||||
|
F598B112F4D33953984FEFF4 /* WebDAV.swift in Sources */,
|
||||||
|
167672C5B1FBD70656C2FEED /* TransferTypes.swift in Sources */,
|
||||||
|
852C0C8EBA96CB6389DF6917 /* SharedAccount.swift in Sources */,
|
||||||
|
3DEDE94F2A4C921407EF4382 /* ShareUpload.swift in Sources */,
|
||||||
|
3C1A0DAA5F9B37FEA4C604BE /* DavFolders.swift in Sources */,
|
||||||
|
66AFC246E251554A80B2476F /* SharePickerView.swift in Sources */,
|
||||||
|
A16680889B3A9D8672FA29C1 /* TransferNotifications.swift in Sources */,
|
||||||
|
8C73B4CB445AEF383B7640E1 /* DeviceAuth.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -284,6 +486,22 @@
|
|||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||||
|
FDFA55C4921747A9CBCBBEEC /* TransferManager.swift in Sources */,
|
||||||
|
5694337ECF8D3ADD3695CBCD /* NativeServices.swift in Sources */,
|
||||||
|
F8D51B4045340275FBD12D16 /* SharedInbox.swift in Sources */,
|
||||||
|
43DB9AB631DD359AEC613A1D /* WebDAV.swift in Sources */,
|
||||||
|
134157C4C8F53C9997FF265C /* TransferTypes.swift in Sources */,
|
||||||
|
E295E45196E3973A5F7E1D8E /* SharedAccount.swift in Sources */,
|
||||||
|
62028E64976F15B4FDEDA9F1 /* ShareUpload.swift in Sources */,
|
||||||
|
A3B3AB501C29516E20455F23 /* DavFolders.swift in Sources */,
|
||||||
|
44078FC9474337A0E102B524 /* TransferNotifications.swift in Sources */,
|
||||||
|
721B9C528F23F144966483E0 /* SyncModels.swift in Sources */,
|
||||||
|
4618DB104BDFF73311A5DA6E /* SyncStore.swift in Sources */,
|
||||||
|
0EA689E7D32E498936A37CDC /* DavSyncClient.swift in Sources */,
|
||||||
|
87DD0AED8048F7F7FF908FDB /* SyncDiff.swift in Sources */,
|
||||||
|
47EF34D26CA64490D2118B0D /* SyncRunner.swift in Sources */,
|
||||||
|
B749ED5B4464A1950E1A4B18 /* SyncConfigStore.swift in Sources */,
|
||||||
|
187CEAB4C9995B379F33409F /* SyncCoordinator.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -295,6 +513,12 @@
|
|||||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
6E0BAC83BFDF8623DBFB9D75 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = ShareExtension;
|
||||||
|
target = F9BE9B2F18B10324D7B84776 /* ShareExtension */;
|
||||||
|
targetProxy = 850C66E713F7C56BDA822FB7 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
/* Begin PBXVariantGroup section */
|
||||||
@@ -360,7 +584,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -375,6 +599,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -437,6 +662,89 @@
|
|||||||
};
|
};
|
||||||
name = Profile;
|
name = Profile;
|
||||||
};
|
};
|
||||||
|
3B057BF66761E30DEABF8B30 /* Profile */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Profile;
|
||||||
|
};
|
||||||
|
6BF252CEA7A55024A2F58A36 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
6FB3F72FB5A94DE254FF4FBF /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
97C147031CF9000F007C117D /* Debug */ = {
|
97C147031CF9000F007C117D /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
@@ -486,7 +794,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -537,7 +845,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
SUPPORTED_PLATFORMS = iphoneos;
|
||||||
@@ -554,6 +862,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -576,6 +885,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -624,6 +934,16 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
E1076120E395D274D37607ED /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
6BF252CEA7A55024A2F58A36 /* Release */,
|
||||||
|
6FB3F72FB5A94DE254FF4FBF /* Debug */,
|
||||||
|
3B057BF66761E30DEABF8B30 /* Profile */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
@@ -7,10 +8,52 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
TransferManager.shared.reconnect()
|
||||||
|
// BGTask handlers have to be registered before launch finishes.
|
||||||
|
SyncCoordinator.shared.registerBackgroundTasks()
|
||||||
|
SyncCoordinator.shared.registerNotificationCategories()
|
||||||
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
SyncCoordinator.shared.scheduleBackgroundWork()
|
||||||
|
return launched
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Taps on a sync-conflict notification's "Keep local" / "Use server".
|
||||||
|
override func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
if SyncCoordinator.shared.handleNotificationResponse(response, completion: completionHandler) { return }
|
||||||
|
super.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transfer summary ("Downloaded 1 file") usually finishes while the app
|
||||||
|
/// is open; without this iOS delivers it silently to Notification Center
|
||||||
|
/// instead of showing the banner.
|
||||||
|
override func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler([.banner, .list, .sound])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
if identifier == TransferManager.sessionIdentifier || identifier == ShareUpload.sessionIdentifier {
|
||||||
|
TransferManager.shared.backgroundCompletionHandler = completionHandler
|
||||||
|
} else {
|
||||||
|
super.application(
|
||||||
|
application, handleEventsForBackgroundURLSession: identifier, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
NativeServices.register(messenger: engineBridge.applicationRegistrar.messenger())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -53,6 +53,26 @@
|
|||||||
<string>LaunchScreen</string>
|
<string>LaunchScreen</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
<key>UIMainStoryboardFile</key>
|
||||||
<string>Main</string>
|
<string>Main</string>
|
||||||
|
<key>UIFileSharingEnabled</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
|
<key>NooKeychainAccessGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>dev.ayushya.noo.sync.refresh</string>
|
||||||
|
<string>dev.ayushya.noo.sync.processing</string>
|
||||||
|
</array>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>fetch</string>
|
||||||
|
<string>processing</string>
|
||||||
|
</array>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Noo uses Face ID to unlock the app and to protect hidden files.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>Noo needs access to your photos and videos so you can choose which ones to upload to your server.</string>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import Flutter
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Holds the one native-side listener of an `EventChannel` so Swift code can
|
||||||
|
/// push events to Dart whenever they happen.
|
||||||
|
final class SinkStreamHandler: NSObject, FlutterStreamHandler {
|
||||||
|
var sink: FlutterEventSink?
|
||||||
|
|
||||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||||
|
-> FlutterError?
|
||||||
|
{
|
||||||
|
sink = events
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
|
sink = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The iOS side of the `dev.ayushya.noo/*` channels `lib/services/` talks to
|
||||||
|
/// (Android's lives in `MainActivity.kt`). Upload, download and share (the
|
||||||
|
/// receiving half of the Share Extension) are implemented so far; the rest
|
||||||
|
/// still throw `MissingPluginException`, which the Dart side treats as
|
||||||
|
/// "unavailable" (`lib/services/native_channel.dart`).
|
||||||
|
enum NativeServices {
|
||||||
|
private static let uploadStatus = SinkStreamHandler()
|
||||||
|
|
||||||
|
static func register(messenger: FlutterBinaryMessenger) {
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/upload_service", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "startUpload" else { return result(FlutterMethodNotImplemented) }
|
||||||
|
handle(call, result) { args in
|
||||||
|
let files = try parseJSONList(args["files"])
|
||||||
|
try TransferManager.shared.startUpload(
|
||||||
|
serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"),
|
||||||
|
files: files.compactMap { entry in
|
||||||
|
guard let uri = entry["uri"] as? String, let name = entry["name"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return UploadFile(uri: uri, name: name)
|
||||||
|
},
|
||||||
|
remoteFolder: (args["remoteFolder"] as? String) ?? "/"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/download_service", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "startDownload" else { return result(FlutterMethodNotImplemented) }
|
||||||
|
handle(call, result) { args in
|
||||||
|
let items = try parseJSONList(args["files"])
|
||||||
|
try TransferManager.shared.startDownload(
|
||||||
|
serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"),
|
||||||
|
items: items.compactMap { entry in
|
||||||
|
guard let path = entry["path"] as? String, let name = entry["name"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (path: path, name: name)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterEventChannel(name: "dev.ayushya.noo/upload_service/status", binaryMessenger: messenger)
|
||||||
|
.setStreamHandler(uploadStatus)
|
||||||
|
TransferManager.shared.onUploadBatchFinished = { folder, succeeded, failed in
|
||||||
|
uploadStatus.sink?(["remoteFolder": folder, "succeeded": succeeded, "failed": failed])
|
||||||
|
}
|
||||||
|
TransferManager.shared.reconnect()
|
||||||
|
registerShare(messenger: messenger)
|
||||||
|
registerShareAccount(messenger: messenger)
|
||||||
|
registerSync(messenger: messenger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Device sync
|
||||||
|
|
||||||
|
/// Sends the current sync status the moment Dart starts listening, then
|
||||||
|
/// every change - same as Android's `SyncStatusBus.subscribe`.
|
||||||
|
private final class SyncStatusStream: NSObject, FlutterStreamHandler {
|
||||||
|
var sink: FlutterEventSink?
|
||||||
|
|
||||||
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||||
|
-> FlutterError?
|
||||||
|
{
|
||||||
|
sink = events
|
||||||
|
let coordinator = SyncCoordinator.shared
|
||||||
|
events(coordinator.statusMap(coordinator.bus.snapshot()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
|
sink = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let syncStatusStream = SyncStatusStream()
|
||||||
|
/// Serial, so status events reach Dart in the order they happened even
|
||||||
|
/// though building each one reads the state from disk.
|
||||||
|
private static let syncStatusQueue = DispatchQueue(label: "dev.ayushya.noo.sync-status", qos: .utility)
|
||||||
|
|
||||||
|
/// `sync_service`: the same methods Android's `MainActivity.kt` serves,
|
||||||
|
/// backed by `SyncCoordinator`/`SyncRunner`.
|
||||||
|
private static func registerSync(messenger: FlutterBinaryMessenger) {
|
||||||
|
let coordinator = SyncCoordinator.shared
|
||||||
|
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/sync_service", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
let args = call.arguments as? [String: Any] ?? [:]
|
||||||
|
switch call.method {
|
||||||
|
case "reschedule":
|
||||||
|
handle(call, result) { _ in coordinator.reschedule(try syncConfig(args)) }
|
||||||
|
case "cancel":
|
||||||
|
// `forget` (default true) = signed out / removed; false = only the
|
||||||
|
// background job is off - credentials stay for manual runs and
|
||||||
|
// conflict actions.
|
||||||
|
handle(call, result) { _ in
|
||||||
|
coordinator.cancel(
|
||||||
|
accountId: try string(args, "accountId"), forget: args["forget"] as? Bool ?? true)
|
||||||
|
}
|
||||||
|
case "syncNow":
|
||||||
|
handle(call, result) { _ in
|
||||||
|
let force = args["force"] as? Bool ?? true
|
||||||
|
coordinator.syncNow(try syncConfig(args), force: force, wifiOnly: args["wifiOnly"] as? Bool)
|
||||||
|
}
|
||||||
|
case "getSyncStatus":
|
||||||
|
let accountId = args["accountId"] as? String
|
||||||
|
syncStatusQueue.async {
|
||||||
|
let map = coordinator.statusMap(coordinator.bus.snapshot(), accountIdOverride: accountId)
|
||||||
|
DispatchQueue.main.async { result(map) }
|
||||||
|
}
|
||||||
|
case "resolveConflict":
|
||||||
|
handle(call, result) { _ in
|
||||||
|
let conflict = SyncConflict(
|
||||||
|
accountId: try string(args, "accountId"), fileId: try string(args, "fileId"),
|
||||||
|
remotePath: try string(args, "remotePath"), relPath: try string(args, "relPath"),
|
||||||
|
name: ((args["relPath"] as? String ?? "") as NSString).lastPathComponent)
|
||||||
|
let creds = SyncCredentials(
|
||||||
|
serverUrl: try string(args, "serverUrl"), username: try string(args, "username"),
|
||||||
|
authHeader: try string(args, "authHeader"))
|
||||||
|
let resolution = try string(args, "resolution")
|
||||||
|
// Fire and forget, like Android: the status stream reports the result.
|
||||||
|
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
|
||||||
|
}
|
||||||
|
case "removeLocalSync":
|
||||||
|
guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil))
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await coordinator.removeLocalSync(accountId: accountId, path: path)
|
||||||
|
await MainActor.run { result(nil) }
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FlutterEventChannel(name: "dev.ayushya.noo/sync_service/status", binaryMessenger: messenger)
|
||||||
|
.setStreamHandler(syncStatusStream)
|
||||||
|
coordinator.bus.onChange = { status in
|
||||||
|
syncStatusQueue.async {
|
||||||
|
let map = coordinator.statusMap(status)
|
||||||
|
DispatchQueue.main.async { syncStatusStream.sink?(map) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account credentials + settings every sync call carries, as a config.
|
||||||
|
private static func syncConfig(_ args: [String: Any]) throws -> SyncAccountConfig {
|
||||||
|
var folders: [String] = []
|
||||||
|
if let json = args["folders"] as? String, let data = json.data(using: .utf8),
|
||||||
|
let list = try? JSONSerialization.jsonObject(with: data) as? [String]
|
||||||
|
{
|
||||||
|
folders = list
|
||||||
|
}
|
||||||
|
return SyncAccountConfig(
|
||||||
|
accountId: try string(args, "accountId"), serverUrl: try string(args, "serverUrl"),
|
||||||
|
username: try string(args, "username"), authHeader: try string(args, "authHeader"),
|
||||||
|
folders: folders, wifiOnly: args["wifiOnly"] as? Bool ?? true,
|
||||||
|
intervalMinutes: args["intervalMinutes"] as? Int, notify: args["notify"] as? Bool ?? true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Account for the Share Extension
|
||||||
|
|
||||||
|
/// `share_account`: Dart hands native every account that can upload, which
|
||||||
|
/// one is active, and the app-lock settings, so the Share Extension - a
|
||||||
|
/// separate process with no Flutter engine - can list folders and upload as
|
||||||
|
/// them. Stored in the shared Keychain group.
|
||||||
|
private static func registerShareAccount(messenger: FlutterBinaryMessenger) {
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/share_account", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
switch call.method {
|
||||||
|
case "setAccounts":
|
||||||
|
handle(call, result) { args in
|
||||||
|
guard let json = args["accounts"] as? String,
|
||||||
|
let data = json.data(using: .utf8),
|
||||||
|
let accounts = try? JSONDecoder().decode(SharedAccounts.self, from: data)
|
||||||
|
else { throw TransferError(message: "Bad accounts payload.") }
|
||||||
|
try SharedAccountStore.save(accounts)
|
||||||
|
NSLog("[ShareAccounts] saved %d account(s), active=%@", accounts.accounts.count, accounts.activeId ?? "none")
|
||||||
|
}
|
||||||
|
case "clearAccount":
|
||||||
|
SharedAccountStore.clear()
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share Extension inbox
|
||||||
|
|
||||||
|
private static let shareEvents = SinkStreamHandler()
|
||||||
|
|
||||||
|
/// `share_intent`: files the Share Extension left in the App Group inbox.
|
||||||
|
/// Cold start asks once (`getInitialShare`); a share that arrives while the
|
||||||
|
/// app is running is pushed when it next becomes active (the extension
|
||||||
|
/// can't signal the app any other way).
|
||||||
|
private static func registerShare(messenger: FlutterBinaryMessenger) {
|
||||||
|
FlutterMethodChannel(name: "dev.ayushya.noo/share_intent", binaryMessenger: messenger)
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "getInitialShare" else { return result(FlutterMethodNotImplemented) }
|
||||||
|
result(consumeShared().map(encode))
|
||||||
|
}
|
||||||
|
FlutterEventChannel(name: "dev.ayushya.noo/share_intent/new", binaryMessenger: messenger)
|
||||||
|
.setStreamHandler(shareEvents)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
||||||
|
) { _ in
|
||||||
|
TransferManager.shared.deliverExtensionUploads()
|
||||||
|
guard let sink = shareEvents.sink else { return }
|
||||||
|
let items = consumeShared()
|
||||||
|
if !items.isEmpty { sink(items.map(encode)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func consumeShared() -> [SharedItem] {
|
||||||
|
guard let root = AppGroup.containerURL else { return [] }
|
||||||
|
return SharedInbox.consume(in: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encode(_ item: SharedItem) -> [String: Any] {
|
||||||
|
[
|
||||||
|
"uri": URL(fileURLWithPath: item.path).absoluteString,
|
||||||
|
"name": item.name,
|
||||||
|
"mimeType": item.mimeType ?? NSNull(),
|
||||||
|
"size": item.size,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Argument helpers
|
||||||
|
|
||||||
|
/// Runs [work] with the call's argument map, turning a thrown error into
|
||||||
|
/// the `FlutterError` Dart's `catch` shows the user.
|
||||||
|
private static func handle(
|
||||||
|
_ call: FlutterMethodCall,
|
||||||
|
_ result: @escaping FlutterResult,
|
||||||
|
work: ([String: Any]) throws -> Void
|
||||||
|
) {
|
||||||
|
guard let args = call.arguments as? [String: Any] else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "Missing arguments.", details: nil))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try work(args)
|
||||||
|
result(nil)
|
||||||
|
} catch {
|
||||||
|
result(FlutterError(code: "transfer_failed", message: error.localizedDescription, details: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func string(_ args: [String: Any], _ key: String) throws -> String {
|
||||||
|
guard let value = args[key] as? String, !value.isEmpty else {
|
||||||
|
throw TransferError(message: "Missing \(key).")
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Dart services pass their file list as one JSON string.
|
||||||
|
private static func parseJSONList(_ value: Any?) throws -> [[String: Any]] {
|
||||||
|
guard let json = value as? String,
|
||||||
|
let data = json.data(using: .utf8),
|
||||||
|
let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
|
||||||
|
else {
|
||||||
|
throw TransferError(message: "Missing file list.")
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Credentials for one account's WebDAV root.
|
||||||
|
struct SyncCredentials {
|
||||||
|
let serverUrl: String
|
||||||
|
let username: String
|
||||||
|
let authHeader: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The WebDAV calls sync needs: PROPFIND (a folder's children, or one item),
|
||||||
|
/// a recursive walk, and GET/PUT of a single file. Foreground
|
||||||
|
/// `URLSession` work - sync runs inside the app or a `BGTask`, not on a
|
||||||
|
/// background session, so a run can walk, diff and decide in one go.
|
||||||
|
///
|
||||||
|
/// Authenticates by each request's own `Authorization` header only (no cookies,
|
||||||
|
/// no credential cache): two accounts can share a server, and one account's
|
||||||
|
/// session must never answer for another.
|
||||||
|
final class DavSyncClient {
|
||||||
|
private let session: URLSession
|
||||||
|
|
||||||
|
init(session: URLSession = DavSyncClient.makeSession()) {
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeSession() -> URLSession {
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
config.timeoutIntervalForRequest = 30
|
||||||
|
config.timeoutIntervalForResource = 15 * 60
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - PROPFIND
|
||||||
|
|
||||||
|
private static let propfindBody = """
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified/>
|
||||||
|
<d:getcontentlength/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getetag/>
|
||||||
|
<oc:fileid/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Depth-1 PROPFIND of [path]: its direct children only. A clean 404 (the
|
||||||
|
/// folder is genuinely gone) is an empty list; anything else that goes wrong
|
||||||
|
/// throws `SyncRemoteUnavailable`.
|
||||||
|
func propfindChildren(_ creds: SyncCredentials, path: String) async throws -> [SyncRemoteEntry] {
|
||||||
|
try await propfind(creds, path: path, depth: "1", skipSelf: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-0 PROPFIND of [path] itself - nil on a clean 404.
|
||||||
|
func propfindSelf(_ creds: SyncCredentials, path: String) async throws -> SyncRemoteEntry? {
|
||||||
|
try await propfind(creds, path: path, depth: "0", skipSelf: false).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func propfind(
|
||||||
|
_ creds: SyncCredentials, path: String, depth: String, skipSelf: Bool
|
||||||
|
) async throws -> [SyncRemoteEntry] {
|
||||||
|
var clean = path.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !clean.hasPrefix("/") { clean = "/" + clean }
|
||||||
|
guard var url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: clean)
|
||||||
|
else { throw SyncRemoteUnavailable(message: "Invalid server address.") }
|
||||||
|
// Collections are addressed with a trailing slash.
|
||||||
|
if depth == "1", let slashed = URL(string: url.absoluteString + "/") { url = slashed }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PROPFIND"
|
||||||
|
request.setValue(depth, forHTTPHeaderField: "Depth")
|
||||||
|
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = Self.propfindBody.data(using: .utf8)
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let status: Int
|
||||||
|
do {
|
||||||
|
let (body, response) = try await session.data(for: request)
|
||||||
|
data = body
|
||||||
|
status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
} catch {
|
||||||
|
try Self.rethrowIfCancelled(error)
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
if status == 404 { return [] } // genuinely gone
|
||||||
|
guard status == 207 else {
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND \(url.path) -> \(status)")
|
||||||
|
}
|
||||||
|
return try DavSyncParser.entries(from: data, username: creds.username, requestedPath: clean, skipSelf: skipSelf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walks [root] (Depth-1 PROPFINDs, breadth-first) into a flat
|
||||||
|
/// manifest. Throws if *any* level fails - a partial manifest would look
|
||||||
|
/// like the missing part had been deleted on the server.
|
||||||
|
func walk(_ creds: SyncCredentials, root: String) async throws -> [SyncRemoteEntry] {
|
||||||
|
var result: [SyncRemoteEntry] = []
|
||||||
|
var queue = [root]
|
||||||
|
var index = 0
|
||||||
|
while index < queue.count {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let children = try await propfindChildren(creds, path: queue[index])
|
||||||
|
index += 1
|
||||||
|
for child in children {
|
||||||
|
result.append(child)
|
||||||
|
if child.isFolder { queue.append(child.path) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - GET / PUT
|
||||||
|
|
||||||
|
/// Downloads [remotePath] to [destination]. Written to a temp file first and
|
||||||
|
/// moved into place, so an interrupted download never leaves a truncated
|
||||||
|
/// file where a good one used to be. False on any failure.
|
||||||
|
func download(_ creds: SyncCredentials, remotePath: String, to destination: URL) async throws -> Bool {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
||||||
|
else { return false }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
do {
|
||||||
|
let (temp, response) = try await session.download(for: request)
|
||||||
|
guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else {
|
||||||
|
try? FileManager.default.removeItem(at: temp)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let fm = FileManager.default
|
||||||
|
try fm.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
if fm.fileExists(atPath: destination.path, isDirectory: &isDirectory) {
|
||||||
|
// Never replace a folder with a file, and replace a file atomically:
|
||||||
|
// the old copy stays until the new one is fully in place.
|
||||||
|
guard !isDirectory.boolValue else {
|
||||||
|
try? fm.removeItem(at: temp)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = try fm.replaceItemAt(destination, withItemAt: temp)
|
||||||
|
} else {
|
||||||
|
try fm.moveItem(at: temp, to: destination)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
try Self.rethrowIfCancelled(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUTs [source] to [remotePath]. False on any failure.
|
||||||
|
func upload(_ creds: SyncCredentials, remotePath: String, from source: URL) async throws -> Bool {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: creds.serverUrl, username: creds.username, remotePath: remotePath)
|
||||||
|
else { return false }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(creds.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
do {
|
||||||
|
let (_, response) = try await session.upload(for: request, fromFile: source)
|
||||||
|
guard let status = (response as? HTTPURLResponse)?.statusCode else { return false }
|
||||||
|
return (200..<300).contains(status)
|
||||||
|
} catch {
|
||||||
|
try Self.rethrowIfCancelled(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cancelled run must stop, not be mistaken for a flaky network.
|
||||||
|
private static func rethrowIfCancelled(_ error: Error) throws {
|
||||||
|
if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled {
|
||||||
|
throw CancellationError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a PROPFIND multistatus into [SyncRemoteEntry]s. Pure, so it's
|
||||||
|
/// unit-tested.
|
||||||
|
enum DavSyncParser {
|
||||||
|
private struct Raw {
|
||||||
|
var href = ""
|
||||||
|
var etag = ""
|
||||||
|
var modified = ""
|
||||||
|
var length = ""
|
||||||
|
var fileId = ""
|
||||||
|
var isCollection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Delegate: NSObject, XMLParserDelegate {
|
||||||
|
var responses: [Raw] = []
|
||||||
|
var sawMultistatus = false
|
||||||
|
private var current: Raw?
|
||||||
|
private var text = ""
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||||
|
) {
|
||||||
|
text = ""
|
||||||
|
if elementName == "multistatus" { sawMultistatus = true }
|
||||||
|
if elementName == "response" { current = Raw() }
|
||||||
|
if elementName == "collection" { current?.isCollection = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||||
|
text += string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?
|
||||||
|
) {
|
||||||
|
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
switch elementName {
|
||||||
|
case "href": current?.href = value
|
||||||
|
case "getetag": current?.etag = value
|
||||||
|
case "getlastmodified": current?.modified = value
|
||||||
|
case "getcontentlength": current?.length = value
|
||||||
|
case "fileid": current?.fileId = value
|
||||||
|
case "response":
|
||||||
|
if let current { responses.append(current) }
|
||||||
|
current = nil
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let httpDate: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Parses a PROPFIND multistatus. Throws `SyncRemoteUnavailable` for
|
||||||
|
/// anything that isn't a complete, plausible answer - malformed or
|
||||||
|
/// truncated XML, no `multistatus` envelope, no responses at all (a 207
|
||||||
|
/// always has at least the requested resource), or an href that isn't
|
||||||
|
/// under this account's files root or tries to climb out of it. A partial
|
||||||
|
/// manifest would look like the missing part was deleted on the server
|
||||||
|
/// (and an empty one like the whole path was), so the caller must treat
|
||||||
|
/// those as "couldn't reach the server", never as data.
|
||||||
|
static func entries(
|
||||||
|
from xml: Data, username: String, requestedPath: String, skipSelf: Bool
|
||||||
|
) throws -> [SyncRemoteEntry] {
|
||||||
|
let delegate = Delegate()
|
||||||
|
let parser = XMLParser(data: xml)
|
||||||
|
parser.shouldProcessNamespaces = true
|
||||||
|
parser.delegate = delegate
|
||||||
|
guard parser.parse(), delegate.sawMultistatus, !delegate.responses.isEmpty else {
|
||||||
|
throw SyncRemoteUnavailable(message: "Unusable PROPFIND answer")
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = "/remote.php/dav/files/\(username)"
|
||||||
|
let selfPath = trimTrailingSlashes(requestedPath)
|
||||||
|
|
||||||
|
var entries: [SyncRemoteEntry] = []
|
||||||
|
for raw in delegate.responses {
|
||||||
|
let decoded = raw.href.removingPercentEncoding ?? raw.href
|
||||||
|
guard let range = decoded.range(of: marker) else {
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND href outside the account's files root")
|
||||||
|
}
|
||||||
|
let hrefPath = String(decoded[range.upperBound...])
|
||||||
|
// A well-formed path continues the marker at a component boundary and
|
||||||
|
// never contains a dot component.
|
||||||
|
guard hrefPath.isEmpty || hrefPath.hasPrefix("/"),
|
||||||
|
!hrefPath.split(separator: "/").contains(where: { $0 == ".." || $0 == "." })
|
||||||
|
else {
|
||||||
|
throw SyncRemoteUnavailable(message: "PROPFIND href has an unexpected path")
|
||||||
|
}
|
||||||
|
let normalized = trimTrailingSlashes(hrefPath.isEmpty ? "/" : hrefPath)
|
||||||
|
if skipSelf && normalized == selfPath { continue } // the folder itself, not a child
|
||||||
|
entries.append(
|
||||||
|
SyncRemoteEntry(
|
||||||
|
path: normalized.isEmpty ? "/" : normalized,
|
||||||
|
fileId: raw.fileId.isEmpty ? (normalized.isEmpty ? "/" : normalized) : raw.fileId,
|
||||||
|
etag: raw.etag.trimmingCharacters(in: CharacterSet(charactersIn: "\"")),
|
||||||
|
lastModified: httpDate.date(from: raw.modified).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0,
|
||||||
|
size: Int64(raw.length) ?? 0,
|
||||||
|
isFolder: raw.isCollection))
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func trimTrailingSlashes(_ path: String) -> String {
|
||||||
|
var p = path
|
||||||
|
while p.hasSuffix("/") { p.removeLast() }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Every account's sync settings and credentials, kept in the Keychain (the
|
||||||
|
/// auth header is a secret) as one JSON item. A background run - which may
|
||||||
|
/// start while the app isn't running - reads these to know what to sync and
|
||||||
|
/// as whom, and a conflict notification's action looks the account up here
|
||||||
|
/// instead of carrying credentials in the notification itself.
|
||||||
|
///
|
||||||
|
/// `kSecAttrAccessibleAfterFirstUnlock`, not "when unlocked": a background
|
||||||
|
/// task can fire with the phone locked.
|
||||||
|
///
|
||||||
|
/// All accounts live in one item, so a write must never be built on a
|
||||||
|
/// failed read: an unreadable item makes `upsert`/`remove` do nothing (and
|
||||||
|
/// say so) rather than save a map holding only one account, and an update
|
||||||
|
/// replaces the item in place instead of deleting it first.
|
||||||
|
final class SyncConfigStore {
|
||||||
|
private struct KeychainError: Error { let status: OSStatus }
|
||||||
|
|
||||||
|
private let service: String
|
||||||
|
private let account = "configs"
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
init(service: String = "dev.ayushya.noo.sync-configs") {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty if there are none - or if the item can't be read, in which case
|
||||||
|
/// nothing runs in the background until it can.
|
||||||
|
func all() -> [SyncAccountConfig] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return Array(((try? load()) ?? [:]).values).sorted { $0.accountId < $1.accountId }
|
||||||
|
}
|
||||||
|
|
||||||
|
func config(for accountId: String) -> SyncAccountConfig? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return (try? load())?[accountId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False if it couldn't be stored (the previous contents are untouched).
|
||||||
|
@discardableResult
|
||||||
|
func upsert(_ config: SyncAccountConfig) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
do {
|
||||||
|
var configs = try load()
|
||||||
|
configs[config.accountId] = config
|
||||||
|
try save(configs)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
NSLog("[Sync] couldn't store config for %@: %@", config.accountId, String(describing: error))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func remove(accountId: String) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
do {
|
||||||
|
var configs = try load()
|
||||||
|
guard configs.removeValue(forKey: accountId) != nil else { return true }
|
||||||
|
try save(configs)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
NSLog("[Sync] couldn't remove config for %@: %@", accountId, String(describing: error))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain
|
||||||
|
|
||||||
|
private func baseQuery() -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[:]` only when nothing is stored yet; any other failure throws.
|
||||||
|
private func load() throws -> [String: SyncAccountConfig] {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return [:] }
|
||||||
|
guard status == errSecSuccess, let data = result as? Data else { throw KeychainError(status: status) }
|
||||||
|
return try JSONDecoder().decode([String: SyncAccountConfig].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(_ configs: [String: SyncAccountConfig]) throws {
|
||||||
|
guard !configs.isEmpty else {
|
||||||
|
let status = SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError(status: status) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let data = try JSONEncoder().encode(configs) // before touching the item
|
||||||
|
let status = SecItemUpdate(
|
||||||
|
baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary)
|
||||||
|
if status == errSecItemNotFound {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecValueData as String] = data
|
||||||
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
let added = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
if added != errSecSuccess { throw KeychainError(status: added) }
|
||||||
|
} else if status != errSecSuccess {
|
||||||
|
throw KeychainError(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
import BackgroundTasks
|
||||||
|
import Network
|
||||||
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// Whether the network currently allows a sync run: any connection normally,
|
||||||
|
/// but with "Wi-Fi only" nothing metered (cellular, a phone's hotspot) -
|
||||||
|
/// iOS's equivalent of Android's `NetworkType.UNMETERED` work constraint,
|
||||||
|
/// which a `BGTask` doesn't offer.
|
||||||
|
enum NetworkGate {
|
||||||
|
static func allows(wifiOnly: Bool) async -> Bool {
|
||||||
|
let monitor = NWPathMonitor()
|
||||||
|
let queue = DispatchQueue(label: "dev.ayushya.noo.network-gate")
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
var answered = false // only touched on `queue`
|
||||||
|
monitor.pathUpdateHandler = { path in
|
||||||
|
guard !answered else { return }
|
||||||
|
answered = true
|
||||||
|
monitor.cancel()
|
||||||
|
let connected = path.status == .satisfied
|
||||||
|
continuation.resume(returning: connected && (!wifiOnly || !path.isExpensive))
|
||||||
|
}
|
||||||
|
monitor.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs an action at most once - for completing a `BGTask`, which must be
|
||||||
|
/// told it's done exactly one time whether the work finished or the system's
|
||||||
|
/// time ran out first.
|
||||||
|
final class OnceGate {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var done = false
|
||||||
|
private let action: (Bool) -> Void
|
||||||
|
|
||||||
|
init(_ action: @escaping (Bool) -> Void) {
|
||||||
|
self.action = action
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish(_ success: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
if done {
|
||||||
|
lock.unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done = true
|
||||||
|
lock.unlock()
|
||||||
|
action(success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs sync for the app: the "Sync now" / in-app refresh runs the Dart side
|
||||||
|
/// asks for, the periodic background runs iOS grants through
|
||||||
|
/// `BGTaskScheduler`, conflict resolution, and the notifications around them.
|
||||||
|
/// The iOS counterpart of Android's `SyncWorker` scheduling + `MainActivity`'s
|
||||||
|
/// `sync_service` handlers; the actual walk/diff/transfer is `SyncRunner`.
|
||||||
|
///
|
||||||
|
/// Every run - foreground or background - goes through `startRun`, so one
|
||||||
|
/// per-account record knows what's running, "Sync now" can supersede it and a
|
||||||
|
/// quiet check can leave it alone, and signing out or removing a path can
|
||||||
|
/// cancel it. Runs are also serialised against each other (and against
|
||||||
|
/// removal) by one mutex, and carry the account's *generation*: cancelling or
|
||||||
|
/// removing bumps it, so a run that was only queued behind another never
|
||||||
|
/// starts for an account that's gone in the meantime.
|
||||||
|
///
|
||||||
|
/// iOS decides *when* background work runs (typically while charging, on
|
||||||
|
/// Wi-Fi, learned from how you use the phone) - `intervalMinutes` is only the
|
||||||
|
/// earliest it may start, not a schedule, and a run is cut off when the
|
||||||
|
/// system's time budget ends (the runner saves its state as it goes).
|
||||||
|
final class SyncCoordinator {
|
||||||
|
static let shared = SyncCoordinator()
|
||||||
|
|
||||||
|
static let refreshTaskId = "dev.ayushya.noo.sync.refresh"
|
||||||
|
static let processingTaskId = "dev.ayushya.noo.sync.processing"
|
||||||
|
|
||||||
|
static let conflictCategory = "dev.ayushya.noo.sync.conflict"
|
||||||
|
static let keepLocalAction = "dev.ayushya.noo.sync.keepLocal"
|
||||||
|
static let useServerAction = "dev.ayushya.noo.sync.useServer"
|
||||||
|
|
||||||
|
let store: SyncStore
|
||||||
|
let bus: SyncStatusBus
|
||||||
|
let configs: SyncConfigStore
|
||||||
|
private let runner: SyncRunner
|
||||||
|
|
||||||
|
/// One run at a time, app-wide (see `AsyncMutex`).
|
||||||
|
private let mutex = AsyncMutex()
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var running: [String: (token: UUID, task: Task<Void, Never>)] = [:]
|
||||||
|
private var generations: [String: Int] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
store: SyncStore = .shared, bus: SyncStatusBus = SyncStatusBus(),
|
||||||
|
configs: SyncConfigStore = SyncConfigStore(), client: DavSyncClient = DavSyncClient()
|
||||||
|
) {
|
||||||
|
self.store = store
|
||||||
|
self.bus = bus
|
||||||
|
self.configs = configs
|
||||||
|
self.runner = SyncRunner(client: client, store: store, bus: bus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings from Dart
|
||||||
|
|
||||||
|
/// Brings the background job in line with [config] (called whenever the
|
||||||
|
/// synced paths, account or network setting changes). A config with no
|
||||||
|
/// folders means this account has nothing to sync.
|
||||||
|
func reschedule(_ config: SyncAccountConfig) {
|
||||||
|
if config.folders.isEmpty {
|
||||||
|
cancel(accountId: config.accountId, forget: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configs.upsert(config)
|
||||||
|
scheduleBackgroundWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [forget] true: the account was removed or signed out - stop its run and
|
||||||
|
/// drop its settings and credentials. [forget] false: only background sync
|
||||||
|
/// was turned off (manual refresh is still possible) - the credentials stay,
|
||||||
|
/// since a conflict notification's actions and the next "Sync now" need
|
||||||
|
/// them, and a run in progress is left to finish.
|
||||||
|
func cancel(accountId: String, forget: Bool = true) {
|
||||||
|
if forget {
|
||||||
|
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||||
|
configs.remove(accountId: accountId)
|
||||||
|
} else if var config = configs.config(for: accountId) {
|
||||||
|
config.intervalMinutes = nil
|
||||||
|
configs.upsert(config)
|
||||||
|
}
|
||||||
|
scheduleBackgroundWork()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-off run. [force] is an explicit "Sync now" / newly added path:
|
||||||
|
/// a full walk, a summary notification, and it supersedes any run already
|
||||||
|
/// going for this account; without it (pull-to-refresh, foreground timer,
|
||||||
|
/// app resume) it's a quiet check that never interrupts a run in progress.
|
||||||
|
/// [wifiOnly] non-nil means "respect the Wi-Fi-only setting".
|
||||||
|
func syncNow(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) {
|
||||||
|
if config.folders.isEmpty { return }
|
||||||
|
// Keep the background settings this call doesn't carry.
|
||||||
|
var stored = config
|
||||||
|
if let existing = configs.config(for: config.accountId) {
|
||||||
|
stored.intervalMinutes = existing.intervalMinutes
|
||||||
|
stored.wifiOnly = existing.wifiOnly
|
||||||
|
}
|
||||||
|
configs.upsert(stored)
|
||||||
|
startRun(stored.withNotify(config.notify), force: force, wifiOnly: wifiOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops [accountId]'s run, if any. [invalidateQueued] also stops runs that
|
||||||
|
/// are only waiting their turn from ever starting.
|
||||||
|
private func cancelRun(accountId: String, invalidateQueued: Bool) {
|
||||||
|
lock.lock()
|
||||||
|
if invalidateQueued { generations[accountId, default: 0] += 1 }
|
||||||
|
running[accountId]?.task.cancel()
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generation(of accountId: String) -> Int {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return generations[accountId] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a tracked run, or - for a non-forced one when this account is
|
||||||
|
/// already running - returns nil and leaves the current run alone.
|
||||||
|
@discardableResult
|
||||||
|
private func startRun(_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?) -> Task<Void, Never>? {
|
||||||
|
lock.lock()
|
||||||
|
if let existing = running[config.accountId] {
|
||||||
|
if force {
|
||||||
|
existing.task.cancel()
|
||||||
|
} else {
|
||||||
|
lock.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = UUID()
|
||||||
|
let generation = generations[config.accountId] ?? 0
|
||||||
|
let task = Task { [weak self] in
|
||||||
|
await self?.execute(config, force: force, wifiOnly: wifiOnly, generation: generation)
|
||||||
|
self?.finished(accountId: config.accountId, token: token)
|
||||||
|
}
|
||||||
|
running[config.accountId] = (token, task)
|
||||||
|
lock.unlock()
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finished(accountId: String, token: UUID) {
|
||||||
|
lock.lock()
|
||||||
|
if running[accountId]?.token == token { running[accountId] = nil }
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning sync off for [path]: stop this account's run first and do the
|
||||||
|
/// removal under the same mutex as runs - a run still holding the old
|
||||||
|
/// state would otherwise write it back over the removal (and a transfer
|
||||||
|
/// finishing after it would leave a file the state doesn't know about).
|
||||||
|
func removeLocalSync(accountId: String, path: String) async {
|
||||||
|
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||||
|
_ = try? await mutex.withLock {
|
||||||
|
store.removeLocalSync(accountId: accountId, path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The map Dart's `SyncStatusSnapshot.fromMap` reads. [accountIdOverride]
|
||||||
|
/// is the account Dart is asking about: the bus only learns an account once
|
||||||
|
/// a run has happened in this process, so on a fresh start the durable
|
||||||
|
/// per-file synced state would otherwise be reported as empty.
|
||||||
|
func statusMap(_ status: SyncStatusBus.Status, accountIdOverride: String? = nil) -> [String: Any] {
|
||||||
|
let accountId = accountIdOverride ?? status.accountId
|
||||||
|
// Live syncing state belongs to whichever account last synced.
|
||||||
|
let busMatches = status.accountId == nil || status.accountId == accountId
|
||||||
|
let synced = accountId.map { Array(((try? store.loadState(accountId: $0)) ?? [:]).keys) } ?? []
|
||||||
|
let missing = accountId.map { Array(store.loadMissingRoots(accountId: $0)) } ?? []
|
||||||
|
return [
|
||||||
|
"accountId": accountId as Any,
|
||||||
|
"syncing": busMatches && status.syncing,
|
||||||
|
"syncingFileIds": busMatches ? Array(status.syncingFileIds) : [],
|
||||||
|
"syncedFileIds": synced,
|
||||||
|
"missingRoots": missing,
|
||||||
|
// Conflicts are per account (a file id is only unique within one).
|
||||||
|
"conflicts": status.conflicts.filter { $0.accountId == accountId }.map {
|
||||||
|
[
|
||||||
|
"accountId": $0.accountId, "fileId": $0.fileId, "remotePath": $0.remotePath,
|
||||||
|
"relPath": $0.relPath, "name": $0.name,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Running
|
||||||
|
|
||||||
|
/// One account's pass, serialised against every other run.
|
||||||
|
private func execute(
|
||||||
|
_ config: SyncAccountConfig, force: Bool, wifiOnly: Bool?, generation: Int
|
||||||
|
) async {
|
||||||
|
// A few extra seconds if the app is backgrounded mid-run; if even those
|
||||||
|
// run out, stop this account's run so it saves and ends cleanly.
|
||||||
|
let box = BackgroundTaskBox()
|
||||||
|
box.id = await MainActor.run {
|
||||||
|
UIApplication.shared.beginBackgroundTask(withName: "noo.sync") { [weak self] in
|
||||||
|
self?.cancelRun(accountId: config.accountId, invalidateQueued: false)
|
||||||
|
UIApplication.shared.endBackgroundTask(box.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer { Task { @MainActor in UIApplication.shared.endBackgroundTask(box.id) } }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await mutex.withLock {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
// Gone (signed out / removed) while this was queued?
|
||||||
|
guard self.generation(of: config.accountId) == generation,
|
||||||
|
self.configs.config(for: config.accountId) != nil
|
||||||
|
else { return }
|
||||||
|
if let wifiOnly, await !NetworkGate.allows(wifiOnly: wifiOnly) { return }
|
||||||
|
let summary = await runner.run(config, force: force)
|
||||||
|
if summary.changedAnything && (force || config.notify) {
|
||||||
|
SyncNotifications.summary(summary, username: config.username, accountId: config.accountId)
|
||||||
|
}
|
||||||
|
SyncNotifications.conflicts(summary.conflicts, username: config.username)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cancelled while waiting for its turn - nothing ran.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves a conflict from a notification action, using the stored
|
||||||
|
/// credentials for its account.
|
||||||
|
func resolveConflict(_ conflict: SyncConflict, resolution: String, creds: SyncCredentials) async -> Bool {
|
||||||
|
(try? await mutex.withLock {
|
||||||
|
await runner.resolveConflict(conflict, resolution: resolution, creds: creds)
|
||||||
|
}) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background tasks
|
||||||
|
|
||||||
|
/// Registers the two `BGTask` handlers. Must run before the app finishes
|
||||||
|
/// launching (see `AppDelegate`).
|
||||||
|
func registerBackgroundTasks() {
|
||||||
|
for id in [Self.refreshTaskId, Self.processingTaskId] {
|
||||||
|
BGTaskScheduler.shared.register(forTaskWithIdentifier: id, using: nil) { [weak self] task in
|
||||||
|
self?.handleBackground(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks iOS for the next background run: a short app-refresh slot and a
|
||||||
|
/// longer processing slot (the system picks when, e.g. overnight on power).
|
||||||
|
/// Nothing is requested when no account has background sync turned on.
|
||||||
|
func scheduleBackgroundWork() {
|
||||||
|
let scheduler = BGTaskScheduler.shared
|
||||||
|
let due = backgroundConfigs()
|
||||||
|
guard let minutes = due.compactMap(\.intervalMinutes).min() else {
|
||||||
|
scheduler.cancel(taskRequestWithIdentifier: Self.refreshTaskId)
|
||||||
|
scheduler.cancel(taskRequestWithIdentifier: Self.processingTaskId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same floor as Android's periodic work.
|
||||||
|
let earliest = Date(timeIntervalSinceNow: TimeInterval(max(minutes, 15) * 60))
|
||||||
|
|
||||||
|
let refresh = BGAppRefreshTaskRequest(identifier: Self.refreshTaskId)
|
||||||
|
refresh.earliestBeginDate = earliest
|
||||||
|
let processing = BGProcessingTaskRequest(identifier: Self.processingTaskId)
|
||||||
|
processing.earliestBeginDate = earliest
|
||||||
|
processing.requiresNetworkConnectivity = true
|
||||||
|
processing.requiresExternalPower = false
|
||||||
|
for request in [refresh, processing] as [BGTaskRequest] {
|
||||||
|
do { try scheduler.submit(request) } catch {
|
||||||
|
NSLog("[Sync] couldn't schedule %@: %@", request.identifier, String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func backgroundConfigs() -> [SyncAccountConfig] {
|
||||||
|
configs.all().filter { $0.intervalMinutes != nil && !$0.folders.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A background slot: runs every account that has background sync on, each
|
||||||
|
/// through the normal tracked path (so it can be cancelled by sign-out, and
|
||||||
|
/// a run already going is left alone). The task is reported complete
|
||||||
|
/// exactly once - when the work ends or when the system's time does.
|
||||||
|
private func handleBackground(_ task: BGTask) {
|
||||||
|
scheduleBackgroundWork() // line up the next one before this one runs
|
||||||
|
task.expirationHandler = startBackgroundSlot { task.setTaskCompleted(success: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a background slot and returns its expiration handler.
|
||||||
|
@discardableResult
|
||||||
|
func startBackgroundSlot(completion: @escaping (Bool) -> Void) -> () -> Void {
|
||||||
|
let gate = OnceGate(completion)
|
||||||
|
let accountIds = backgroundConfigs().map(\.accountId)
|
||||||
|
let work = Task { [weak self] in
|
||||||
|
guard let self else { return gate.finish(false) }
|
||||||
|
for config in self.backgroundConfigs() {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await self.startRun(config, force: false, wifiOnly: config.wifiOnly)?.value
|
||||||
|
}
|
||||||
|
gate.finish(!Task.isCancelled)
|
||||||
|
}
|
||||||
|
// The system's time is up: stop the work and the runs it started (each
|
||||||
|
// saves what it has) and report.
|
||||||
|
return { [weak self] in
|
||||||
|
work.cancel()
|
||||||
|
for id in accountIds { self?.cancelRun(accountId: id, invalidateQueued: false) }
|
||||||
|
gate.finish(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
/// Registers the conflict notification's actions. Call once at launch.
|
||||||
|
func registerNotificationCategories() {
|
||||||
|
let keep = UNNotificationAction(identifier: Self.keepLocalAction, title: "Keep local", options: [])
|
||||||
|
let server = UNNotificationAction(identifier: Self.useServerAction, title: "Use server", options: [])
|
||||||
|
let category = UNNotificationCategory(
|
||||||
|
identifier: Self.conflictCategory, actions: [keep, server], intentIdentifiers: [], options: [])
|
||||||
|
UNUserNotificationCenter.current().setNotificationCategories([category])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles a tap on a conflict notification's action. Returns whether the
|
||||||
|
/// response was ours (so `AppDelegate` knows not to pass it on).
|
||||||
|
@MainActor
|
||||||
|
func handleNotificationResponse(
|
||||||
|
_ response: UNNotificationResponse, completion: @escaping () -> Void
|
||||||
|
) -> Bool {
|
||||||
|
let content = response.notification.request.content
|
||||||
|
guard content.categoryIdentifier == Self.conflictCategory else { return false }
|
||||||
|
let resolution: String
|
||||||
|
switch response.actionIdentifier {
|
||||||
|
case Self.keepLocalAction: resolution = "local"
|
||||||
|
case Self.useServerAction: resolution = "server"
|
||||||
|
default:
|
||||||
|
completion() // plain tap: just opens the app
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let info = content.userInfo
|
||||||
|
guard let accountId = info["accountId"] as? String,
|
||||||
|
let fileId = info["fileId"] as? String,
|
||||||
|
let remotePath = info["remotePath"] as? String,
|
||||||
|
let relPath = info["relPath"] as? String,
|
||||||
|
let config = configs.config(for: accountId)
|
||||||
|
else {
|
||||||
|
completion()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let conflict = SyncConflict(
|
||||||
|
accountId: accountId, fileId: fileId, remotePath: remotePath, relPath: relPath,
|
||||||
|
name: (relPath as NSString).lastPathComponent)
|
||||||
|
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||||
|
let box = BackgroundTaskBox()
|
||||||
|
box.id = UIApplication.shared.beginBackgroundTask(withName: "noo.sync.resolve") {
|
||||||
|
completion()
|
||||||
|
UIApplication.shared.endBackgroundTask(box.id)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
let ok = await self.resolveConflict(conflict, resolution: resolution, creds: creds)
|
||||||
|
if ok {
|
||||||
|
UNUserNotificationCenter.current().removeDeliveredNotifications(
|
||||||
|
withIdentifiers: [response.notification.request.identifier])
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
await MainActor.run { UIApplication.shared.endBackgroundTask(box.id) }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds a `UIBackgroundTaskIdentifier` so an expiration handler (created
|
||||||
|
/// before the identifier exists) can end the task it belongs to.
|
||||||
|
final class BackgroundTaskBox {
|
||||||
|
var id: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SyncAccountConfig {
|
||||||
|
func withNotify(_ notify: Bool) -> SyncAccountConfig {
|
||||||
|
var copy = self
|
||||||
|
copy.notify = notify
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local notifications sync posts: a summary of what changed, and one per
|
||||||
|
/// conflict (which always posts - it needs a decision) with Keep local / Use
|
||||||
|
/// server actions.
|
||||||
|
enum SyncNotifications {
|
||||||
|
static func summary(_ summary: SyncRunSummary, username: String, accountId: String) {
|
||||||
|
var parts: [String] = []
|
||||||
|
if summary.downloaded > 0 { parts.append("\(summary.downloaded) updated") }
|
||||||
|
if summary.uploaded > 0 { parts.append("\(summary.uploaded) uploaded") }
|
||||||
|
if summary.deleted > 0 { parts.append("\(summary.deleted) removed") }
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo sync · \(username)"
|
||||||
|
content.body = parts.joined(separator: ", ")
|
||||||
|
// One per account, so a newer summary replaces an older one in place.
|
||||||
|
post(content, id: "sync.summary.\(accountId)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func conflicts(_ conflicts: [SyncConflict], username: String) {
|
||||||
|
for conflict in conflicts {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Sync conflict: \(conflict.name)"
|
||||||
|
content.body = "\(username) - changed both on this device and on the server."
|
||||||
|
content.categoryIdentifier = SyncCoordinator.conflictCategory
|
||||||
|
content.userInfo = [
|
||||||
|
"accountId": conflict.accountId, "fileId": conflict.fileId,
|
||||||
|
"remotePath": conflict.remotePath, "relPath": conflict.relPath,
|
||||||
|
]
|
||||||
|
post(content, id: "sync.conflict.\(conflict.accountId).\(conflict.relPath)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(_ content: UNMutableNotificationContent, id: String) {
|
||||||
|
UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: id, content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A local file's size and modification time (ms), the pair sync compares
|
||||||
|
/// against what it recorded.
|
||||||
|
struct LocalStat: Equatable {
|
||||||
|
let size: Int64
|
||||||
|
let mtimeMs: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocalFS {
|
||||||
|
/// nil if [url] doesn't exist.
|
||||||
|
static func stat(_ url: URL) -> LocalStat? {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
|
||||||
|
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
let mtime = (attrs[.modificationDate] as? Date).map { Int64($0.timeIntervalSince1970 * 1000) } ?? 0
|
||||||
|
return LocalStat(size: size, mtimeMs: mtime)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relative(_ path: String) -> String {
|
||||||
|
path.hasPrefix("/") ? String(path.dropFirst()) : path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func trimTrailingSlashes(_ path: String) -> String {
|
||||||
|
var p = path
|
||||||
|
while p.hasSuffix("/") { p.removeLast() }
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [rel] under [root], or nil if it isn't safely inside it: empty, with a
|
||||||
|
/// `.`/`..` component, or resolving outside [root]. Every path that came
|
||||||
|
/// from the server goes through this before anything is read, written or
|
||||||
|
/// deleted, so a hostile or buggy server can't point sync at other files.
|
||||||
|
static func resolve(_ rel: String, in root: URL) -> URL? {
|
||||||
|
let parts = rel.split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
guard !parts.isEmpty, !parts.contains(where: { $0 == ".." || $0 == "." }) else { return nil }
|
||||||
|
let url = root.appendingPathComponent(rel)
|
||||||
|
let rootPath = root.standardizedFileURL.path
|
||||||
|
guard url.standardizedFileURL.path.hasPrefix(rootPath + "/") else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decisions of a sync pass, kept free of networking so they can be
|
||||||
|
/// unit-tested. A direct port of Android's `SyncEngine.diffFolder` and its
|
||||||
|
/// helpers - the same rules, so a device behaves the same on either platform.
|
||||||
|
enum SyncDiff {
|
||||||
|
/// Diffs one synced path's remote manifest against the recorded state.
|
||||||
|
/// Callers own actually doing the GET/PUT/delete and updating state.
|
||||||
|
///
|
||||||
|
/// - A file with no recorded state is simply pulled.
|
||||||
|
/// - Server changed and device changed -> conflict (never silently overwrite).
|
||||||
|
/// - Only the server changed -> download. Only the device changed -> upload.
|
||||||
|
/// - Recorded but missing on the device -> pull it back (it's inside a path
|
||||||
|
/// the user asked to sync, so a vanished copy is repaired, not propagated).
|
||||||
|
/// - Recorded for this path but absent from a *successful* listing ->
|
||||||
|
/// deleted on the server, so the local copy goes too. (An unreachable
|
||||||
|
/// server never gets here - the caller skips the path instead.)
|
||||||
|
static func diffFolder(
|
||||||
|
entries: [SyncRemoteEntry],
|
||||||
|
state: [String: SyncFileState],
|
||||||
|
priorFolderFileIds: Set<String>,
|
||||||
|
syncRoot: URL
|
||||||
|
) -> [SyncAction] {
|
||||||
|
var actions: [SyncAction] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
|
||||||
|
for entry in entries where !entry.isFolder {
|
||||||
|
seen.insert(entry.fileId)
|
||||||
|
let rel = LocalFS.relative(entry.path)
|
||||||
|
let local = LocalFS.stat(syncRoot.appendingPathComponent(rel))
|
||||||
|
|
||||||
|
guard let prior = state[entry.fileId] else {
|
||||||
|
actions.append(.download(entry))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let serverChanged = entry.etag != prior.etag
|
||||||
|
let localChanged = local.map { $0.mtimeMs != prior.localMTime || $0.size != prior.size } ?? false
|
||||||
|
|
||||||
|
if local == nil {
|
||||||
|
actions.append(.download(entry))
|
||||||
|
} else if serverChanged && localChanged {
|
||||||
|
actions.append(.conflict(entry, relPath: rel))
|
||||||
|
} else if serverChanged {
|
||||||
|
actions.append(.download(entry))
|
||||||
|
} else if localChanged {
|
||||||
|
actions.append(.upload(relPath: rel, fileId: entry.fileId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for fileId in priorFolderFileIds where !seen.contains(fileId) {
|
||||||
|
guard let prior = state[fileId] else { continue }
|
||||||
|
actions.append(.delete(relPath: prior.relPath, fileId: fileId))
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded files that belong to the synced [path]: everything for the
|
||||||
|
/// root ("/"), else the file itself or anything under it - matched on a
|
||||||
|
/// path-component boundary, so "/Docs" never claims "/Docs2" or
|
||||||
|
/// "/Documents".
|
||||||
|
static func priorFileIds(state: [String: SyncFileState], path: String) -> Set<String> {
|
||||||
|
let rootRel = LocalFS.relative(LocalFS.trimTrailingSlashes(path))
|
||||||
|
return Set(
|
||||||
|
state.filter {
|
||||||
|
rootRel.isEmpty || $0.value.relPath == rootRel || $0.value.relPath.hasPrefix(rootRel + "/")
|
||||||
|
}.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local paths (as spelled by the server) that more than one remote entry
|
||||||
|
/// would land on: "a.txt" and "A.txt" on a case-insensitive volume, or two
|
||||||
|
/// canonically-equivalent Unicode spellings. Downloading either would
|
||||||
|
/// overwrite the other, so callers skip them.
|
||||||
|
static func collidingRelPaths(_ entries: [SyncRemoteEntry]) -> Set<String> {
|
||||||
|
var byKey: [String: [String]] = [:]
|
||||||
|
for entry in entries {
|
||||||
|
let rel = LocalFS.relative(entry.path)
|
||||||
|
byKey[rel.precomposedStringWithCanonicalMapping.lowercased(), default: []].append(rel)
|
||||||
|
}
|
||||||
|
// Compared as raw bytes: Swift's own `String` equality already treats
|
||||||
|
// canonically equivalent spellings as the same string, which is exactly
|
||||||
|
// the distinction the server preserves and the disk may not.
|
||||||
|
return Set(byKey.values.filter { Set($0.map { Array($0.utf8) }).count > 1 }.flatMap { $0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if every file in [fileIds] is still on disk exactly as recorded
|
||||||
|
/// (same size and mtime): nothing local to upload and nothing missing to
|
||||||
|
/// re-download. Pure local stat calls, no network.
|
||||||
|
static func localMatchesState(
|
||||||
|
state: [String: SyncFileState], fileIds: Set<String>, syncRoot: URL
|
||||||
|
) -> Bool {
|
||||||
|
fileIds.allSatisfy { id in
|
||||||
|
guard let s = state[id], let local = LocalFS.stat(syncRoot.appendingPathComponent(s.relPath)) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return local.size == s.size && local.mtimeMs == s.localMTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a local directory for every remote folder, so empty folders
|
||||||
|
/// exist on the device too.
|
||||||
|
static func mirrorFolders(
|
||||||
|
entries: [SyncRemoteEntry], syncRoot: URL, rootRel: String, collisions: Set<String> = []
|
||||||
|
) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if !rootRel.isEmpty, let dir = LocalFS.resolve(rootRel, in: syncRoot) {
|
||||||
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
for entry in entries where entry.isFolder {
|
||||||
|
let rel = entry.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
// Skip anything unsafe or that would share a local folder with another
|
||||||
|
// remote folder (a/A on a case-insensitive volume).
|
||||||
|
guard !collisions.contains(rel), let dir = LocalFS.resolve(rel, in: syncRoot) else { continue }
|
||||||
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes local directories under [rootRel] that no longer exist on the
|
||||||
|
/// server ([remoteFolders], relative paths). Only *empty* directories go:
|
||||||
|
/// by the time this runs the diff has already deleted the files that were
|
||||||
|
/// inside a deleted server folder, 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.
|
||||||
|
static func pruneRemovedFolders(syncRoot: URL, rootRel: String, remoteFolders: Set<String>) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let top = rootRel.isEmpty ? syncRoot : syncRoot.appendingPathComponent(rootRel)
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard fm.fileExists(atPath: top.path, isDirectory: &isDir), isDir.boolValue else { return }
|
||||||
|
|
||||||
|
func prune(_ dir: URL, rel: String) {
|
||||||
|
let children = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isDirectoryKey])) ?? []
|
||||||
|
for child in children {
|
||||||
|
let childIsDir = (try? child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||||
|
if childIsDir {
|
||||||
|
prune(child, rel: rel.isEmpty ? child.lastPathComponent : rel + "/" + child.lastPathComponent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !rel.isEmpty, !remoteFolders.contains(rel),
|
||||||
|
let remaining = try? fm.contentsOfDirectory(atPath: dir.path), remaining.isEmpty
|
||||||
|
else { return }
|
||||||
|
// rmdir, not removeItem: it refuses a directory that isn't empty, so a
|
||||||
|
// file that appeared since the check survives.
|
||||||
|
rmdir(dir.path)
|
||||||
|
}
|
||||||
|
prune(top, rel: rootRel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// The iOS port of Android's `SyncEngine.kt`/`SyncWorker.kt` data model. Times
|
||||||
|
// are milliseconds since the epoch, like the Kotlin side, so the persisted
|
||||||
|
// state means the same thing on both platforms.
|
||||||
|
|
||||||
|
/// One file or folder the server reported in a PROPFIND.
|
||||||
|
struct SyncRemoteEntry: Equatable {
|
||||||
|
/// Path relative to the user's files root, no trailing slash ("/Docs/a.txt").
|
||||||
|
let path: String
|
||||||
|
let fileId: String
|
||||||
|
let etag: String
|
||||||
|
let lastModified: Int64
|
||||||
|
let size: Int64
|
||||||
|
let isFolder: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the last successful sync recorded for a file - the baseline a later
|
||||||
|
/// pass diffs the server and the device against.
|
||||||
|
struct SyncFileState: Codable, Equatable {
|
||||||
|
let relPath: String
|
||||||
|
let etag: String
|
||||||
|
let lastModified: Int64
|
||||||
|
let size: Int64
|
||||||
|
let localMTime: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configured sync root's etag at the end of its last clean, complete walk.
|
||||||
|
/// Nextcloud propagates a change to any descendant up through every ancestor's
|
||||||
|
/// etag, so an unchanged root etag means nothing beneath it changed.
|
||||||
|
struct SyncRootMarker: Codable, Equatable {
|
||||||
|
let etag: String
|
||||||
|
let fullWalkAt: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SyncAction: Equatable {
|
||||||
|
case download(SyncRemoteEntry)
|
||||||
|
case upload(relPath: String, fileId: String)
|
||||||
|
case delete(relPath: String, fileId: String)
|
||||||
|
case conflict(SyncRemoteEntry, relPath: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file changed both on the device and on the server since the last sync.
|
||||||
|
struct SyncConflict: Codable, Equatable {
|
||||||
|
let accountId: String
|
||||||
|
let fileId: String
|
||||||
|
let remotePath: String
|
||||||
|
let relPath: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a sync run (foreground or background) needs for one account.
|
||||||
|
/// Persisted in the Keychain - it carries the auth header.
|
||||||
|
struct SyncAccountConfig: Codable, Equatable {
|
||||||
|
let accountId: String
|
||||||
|
let serverUrl: String
|
||||||
|
let username: String
|
||||||
|
let authHeader: String
|
||||||
|
/// Remote paths (files or folders) to mirror.
|
||||||
|
var folders: [String]
|
||||||
|
var wifiOnly: Bool
|
||||||
|
/// Minutes between background syncs; nil = no background sync (manual and
|
||||||
|
/// in-app refresh only).
|
||||||
|
var intervalMinutes: Int?
|
||||||
|
/// Whether automatic runs may post a summary notification.
|
||||||
|
var notify: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncRunSummary: Equatable {
|
||||||
|
var downloaded = 0
|
||||||
|
var uploaded = 0
|
||||||
|
var deleted = 0
|
||||||
|
var conflicts: [SyncConflict] = []
|
||||||
|
|
||||||
|
var changedAnything: Bool { downloaded > 0 || uploaded > 0 || deleted > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server couldn't be reached or answered with an error (anything but a
|
||||||
|
/// clean 404). Distinct from "the path is genuinely gone": 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.
|
||||||
|
struct SyncRemoteUnavailable: Error, CustomStringConvertible {
|
||||||
|
let message: String
|
||||||
|
var description: String { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The recorded sync state exists but couldn't be read (corrupt, or an I/O
|
||||||
|
/// error). Treating that as "nothing recorded yet" would make every local
|
||||||
|
/// file look untracked and overwrite local edits with the server's copy, so
|
||||||
|
/// a run that hits it does nothing at all.
|
||||||
|
struct SyncStateUnreadable: Error, CustomStringConvertible {
|
||||||
|
let accountId: String
|
||||||
|
var description: String { "Sync state for \(accountId) is unreadable" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A mutex for `async` code: one holder at a time, waiters resume in order.
|
||||||
|
/// (An `actor` isn't enough - it lets other calls in at every `await`.) Held
|
||||||
|
/// for the whole of any run that reads-then-rewrites a sync state map, which
|
||||||
|
/// is also what keeps `SyncStatusBus` - it tracks one account at a time -
|
||||||
|
/// coherent when several accounts are due.
|
||||||
|
///
|
||||||
|
/// Waiting is cancellable: a task that's cancelled while queued (a background
|
||||||
|
/// run whose time budget expired, a superseded "Sync now") leaves the queue
|
||||||
|
/// at once with `CancellationError` instead of sitting behind a long run.
|
||||||
|
final class AsyncMutex {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var locked = false
|
||||||
|
private var waiters: [(id: UUID, continuation: CheckedContinuation<Void, Error>)] = []
|
||||||
|
|
||||||
|
func withLock<T>(_ body: () async throws -> T) async throws -> T {
|
||||||
|
try await acquire()
|
||||||
|
defer { release() }
|
||||||
|
return try await body()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acquire() async throws {
|
||||||
|
let id = UUID()
|
||||||
|
try await withTaskCancellationHandler {
|
||||||
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
|
lock.lock()
|
||||||
|
if Task.isCancelled {
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume(throwing: CancellationError())
|
||||||
|
} else if !locked {
|
||||||
|
locked = true
|
||||||
|
lock.unlock()
|
||||||
|
continuation.resume()
|
||||||
|
} else {
|
||||||
|
waiters.append((id, continuation))
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} onCancel: {
|
||||||
|
lock.lock()
|
||||||
|
if let index = waiters.firstIndex(where: { $0.id == id }) {
|
||||||
|
let waiter = waiters.remove(at: index)
|
||||||
|
lock.unlock()
|
||||||
|
waiter.continuation.resume(throwing: CancellationError())
|
||||||
|
} else {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func release() {
|
||||||
|
lock.lock()
|
||||||
|
if waiters.isEmpty {
|
||||||
|
locked = false
|
||||||
|
lock.unlock()
|
||||||
|
} else {
|
||||||
|
let next = waiters.removeFirst()
|
||||||
|
lock.unlock()
|
||||||
|
next.continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live sync status for the app: whether a run is going, which files are
|
||||||
|
/// transferring, and unresolved conflicts. In-memory only - what actually
|
||||||
|
/// answers "is this file synced" is the on-disk state; this carries just the
|
||||||
|
/// transient parts. The iOS twin of Android's `SyncStatusBus`, except that
|
||||||
|
/// conflicts are kept per account (a file id is only unique within one).
|
||||||
|
final class SyncStatusBus {
|
||||||
|
struct Status: Equatable {
|
||||||
|
var accountId: String?
|
||||||
|
var syncing = false
|
||||||
|
var syncingFileIds: Set<String> = []
|
||||||
|
/// Every account's unresolved conflicts - readers filter by account.
|
||||||
|
var conflicts: [SyncConflict] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var current = Status()
|
||||||
|
|
||||||
|
/// Called (on whatever thread changed it) after every update.
|
||||||
|
var onChange: ((Status) -> Void)?
|
||||||
|
|
||||||
|
func snapshot() -> Status {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSyncing(accountId: String, _ syncing: Bool) {
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
$0.syncing = syncing
|
||||||
|
if !syncing { $0.syncingFileIds = [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markFileSyncing(accountId: String, fileId: String, _ syncing: Bool) {
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
if syncing { $0.syncingFileIds.insert(fileId) } else { $0.syncingFileIds.remove(fileId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addConflicts(accountId: String, _ new: [SyncConflict]) {
|
||||||
|
guard !new.isEmpty else { return }
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
for conflict in new
|
||||||
|
where !$0.conflicts.contains(where: { $0.accountId == conflict.accountId && $0.fileId == conflict.fileId }) {
|
||||||
|
$0.conflicts.append(conflict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeConflict(accountId: String, fileId: String) {
|
||||||
|
update {
|
||||||
|
$0.accountId = accountId
|
||||||
|
$0.conflicts.removeAll { $0.accountId == accountId && $0.fileId == fileId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(_ mutate: (inout Status) -> Void) {
|
||||||
|
lock.lock()
|
||||||
|
mutate(¤t)
|
||||||
|
let snapshot = current
|
||||||
|
lock.unlock()
|
||||||
|
onChange?(snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One account's sync pass: walk each configured path, diff it against the
|
||||||
|
/// recorded state, transfer, and record the result. The iOS port of Android's
|
||||||
|
/// `SyncWorker.performSync` (and `ConflictResolveWorker`), with the same
|
||||||
|
/// safeguards - and a few more, found in review:
|
||||||
|
///
|
||||||
|
/// - If the server can't be reached for a path - or answers with something
|
||||||
|
/// that isn't a complete, plausible listing - that path is skipped for this
|
||||||
|
/// run, never diffed against an empty or partial listing, which would look
|
||||||
|
/// like files were deleted and wipe the local copies.
|
||||||
|
/// - If the recorded state can't be read the run does nothing at all (it
|
||||||
|
/// would otherwise treat every local file as untracked and overwrite local
|
||||||
|
/// edits with the server's copies).
|
||||||
|
/// - Paths the server sends are checked to stay inside the mirror, and two
|
||||||
|
/// remote files that would land on the same local file are both skipped.
|
||||||
|
/// - Server deletions remove the local copy, including local edits, like Android.
|
||||||
|
/// - A root's etag is only remembered once its whole path synced cleanly, and
|
||||||
|
/// only if the state it relies on was actually saved.
|
||||||
|
struct SyncRunner {
|
||||||
|
let client: DavSyncClient
|
||||||
|
let store: SyncStore
|
||||||
|
let bus: SyncStatusBus
|
||||||
|
var now: () -> Int64 = { Int64(Date().timeIntervalSince1970 * 1000) }
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
static let fullWalkMaxAgeMs: Int64 = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/// State is flushed to disk after this many transfers.
|
||||||
|
static let saveEvery = 20
|
||||||
|
|
||||||
|
/// [force] is an explicit "Sync now" / newly added path: always a full
|
||||||
|
/// walk, never trusting the root-etag shortcut.
|
||||||
|
func run(_ config: SyncAccountConfig, force: Bool) async -> SyncRunSummary {
|
||||||
|
var summary = SyncRunSummary()
|
||||||
|
guard !config.folders.isEmpty else { return summary }
|
||||||
|
let accountId = config.accountId
|
||||||
|
let creds = SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||||
|
let syncRoot = store.syncRoot(accountId: accountId)
|
||||||
|
|
||||||
|
var state: [String: SyncFileState]
|
||||||
|
do {
|
||||||
|
state = try store.loadState(accountId: accountId)
|
||||||
|
} catch {
|
||||||
|
NSLog("[Sync] %@ - skipping this run", String(describing: error))
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
var markers = store.loadRootMarkers(accountId: accountId)
|
||||||
|
var sinceSave = 0
|
||||||
|
|
||||||
|
bus.setSyncing(accountId: accountId, true)
|
||||||
|
defer {
|
||||||
|
// State first, then the markers that depend on it, then announce.
|
||||||
|
if store.saveState(accountId: accountId, state) {
|
||||||
|
markers = markers.filter { config.folders.contains($0.key) }
|
||||||
|
store.saveRootMarkers(accountId: accountId, markers)
|
||||||
|
} else {
|
||||||
|
NSLog("[Sync] couldn't save state for %@; not recording this run's markers", accountId)
|
||||||
|
}
|
||||||
|
bus.setSyncing(accountId: accountId, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferred() {
|
||||||
|
sinceSave += 1
|
||||||
|
if sinceSave >= Self.saveEvery {
|
||||||
|
if !store.saveState(accountId: accountId, state) { NSLog("[Sync] periodic state save failed") }
|
||||||
|
sinceSave = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in config.folders {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
do {
|
||||||
|
// A configured path can be a file or a folder - check which before
|
||||||
|
// deciding whether to walk it or just diff the single item.
|
||||||
|
let selfEntry = try await client.propfindSelf(creds, path: path)
|
||||||
|
let priorIds = SyncDiff.priorFileIds(state: state, path: path)
|
||||||
|
|
||||||
|
// Cheap "did anything change?" check: an unchanged root etag means
|
||||||
|
// nothing beneath it changed on the server, and if every local file
|
||||||
|
// is also still as recorded there's nothing to upload or re-download
|
||||||
|
// either - skip the walk (one Depth-0 PROPFIND instead of one per
|
||||||
|
// subfolder).
|
||||||
|
if !force, let root = selfEntry, root.isFolder, let marker = markers[path],
|
||||||
|
marker.etag == root.etag,
|
||||||
|
now() - marker.fullWalkAt < Self.fullWalkMaxAgeMs,
|
||||||
|
!priorIds.isEmpty,
|
||||||
|
SyncDiff.localMatchesState(state: state, fileIds: priorIds, syncRoot: syncRoot)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server says this synced path no longer exists (a clean 404, not
|
||||||
|
// a network error): flag it so the app drops it from its synced list.
|
||||||
|
store.setRootMissing(accountId: accountId, path: path, missing: selfEntry == nil)
|
||||||
|
|
||||||
|
var entries: [SyncRemoteEntry] = []
|
||||||
|
if let root = selfEntry {
|
||||||
|
if root.isFolder {
|
||||||
|
entries = try await client.walk(creds, root: path)
|
||||||
|
} else {
|
||||||
|
entries = [root]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var pathClean = true
|
||||||
|
let collisions = SyncDiff.collidingRelPaths(entries)
|
||||||
|
|
||||||
|
let actions = SyncDiff.diffFolder(
|
||||||
|
entries: entries, state: state, priorFolderFileIds: priorIds, syncRoot: syncRoot)
|
||||||
|
for action in actions {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
switch action {
|
||||||
|
case .download(let entry):
|
||||||
|
let rel = LocalFS.relative(entry.path)
|
||||||
|
guard !collisions.contains(rel), let dest = LocalFS.resolve(rel, in: syncRoot) else {
|
||||||
|
NSLog("[Sync] not downloading %@: unsafe or colliding local path", rel)
|
||||||
|
pathClean = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, true)
|
||||||
|
let ok = try await client.download(creds, remotePath: entry.path, to: dest)
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: entry.fileId, false)
|
||||||
|
if ok {
|
||||||
|
let local = LocalFS.stat(dest)
|
||||||
|
state[entry.fileId] = SyncFileState(
|
||||||
|
relPath: rel, etag: entry.etag, lastModified: entry.lastModified,
|
||||||
|
size: local?.size ?? entry.size, localMTime: local?.mtimeMs ?? 0)
|
||||||
|
summary.downloaded += 1
|
||||||
|
transferred()
|
||||||
|
} else {
|
||||||
|
pathClean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
case .upload(let rel, let fileId):
|
||||||
|
guard let local = LocalFS.resolve(rel, in: syncRoot) else {
|
||||||
|
pathClean = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: fileId, true)
|
||||||
|
var ok = false
|
||||||
|
if FileManager.default.fileExists(atPath: local.path) {
|
||||||
|
ok = try await client.upload(creds, remotePath: "/" + rel, from: local)
|
||||||
|
}
|
||||||
|
bus.markFileSyncing(accountId: accountId, fileId: fileId, false)
|
||||||
|
if ok, let stat = LocalFS.stat(local) {
|
||||||
|
// Our own PUT changed the file's etag; record the new one, or
|
||||||
|
// the next pass would see "server changed" and re-download it.
|
||||||
|
// If it can't be read back, keep the old one and let the root
|
||||||
|
// be walked for real next time.
|
||||||
|
let fresh = (try? await client.propfindSelf(creds, path: "/" + rel)) ?? nil
|
||||||
|
if fresh == nil { pathClean = false }
|
||||||
|
state[fileId] = SyncFileState(
|
||||||
|
relPath: rel, etag: fresh?.etag ?? state[fileId]?.etag ?? "",
|
||||||
|
lastModified: stat.mtimeMs, size: stat.size, localMTime: stat.mtimeMs)
|
||||||
|
summary.uploaded += 1
|
||||||
|
transferred()
|
||||||
|
} else {
|
||||||
|
pathClean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
case .delete(let rel, let fileId):
|
||||||
|
// Only counts as a removal if a local copy existed and went;
|
||||||
|
// dropping stale state for a file never on disk isn't news.
|
||||||
|
if let local = LocalFS.resolve(rel, in: syncRoot),
|
||||||
|
FileManager.default.fileExists(atPath: local.path),
|
||||||
|
(try? FileManager.default.removeItem(at: local)) != nil
|
||||||
|
{
|
||||||
|
summary.deleted += 1
|
||||||
|
}
|
||||||
|
state[fileId] = nil
|
||||||
|
|
||||||
|
case .conflict(let entry, let rel):
|
||||||
|
summary.conflicts.append(
|
||||||
|
SyncConflict(
|
||||||
|
accountId: accountId, fileId: entry.fileId, remotePath: entry.path, relPath: rel,
|
||||||
|
name: (rel as NSString).lastPathComponent))
|
||||||
|
pathClean = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror the folder structure itself: empty folders created on the
|
||||||
|
// server appear on the device, and folders deleted on the server
|
||||||
|
// (whose files the diff has already removed) don't linger.
|
||||||
|
if selfEntry == nil || selfEntry?.isFolder == true {
|
||||||
|
let rootRel = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
if selfEntry != nil {
|
||||||
|
SyncDiff.mirrorFolders(entries: entries, syncRoot: syncRoot, rootRel: rootRel, collisions: collisions)
|
||||||
|
}
|
||||||
|
var remoteFolders = Set(
|
||||||
|
entries.filter(\.isFolder).map { $0.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) })
|
||||||
|
if selfEntry != nil { remoteFolders.insert(rootRel) }
|
||||||
|
SyncDiff.pruneRemovedFolders(syncRoot: syncRoot, rootRel: rootRel, remoteFolders: remoteFolders)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let root = selfEntry, root.isFolder, pathClean {
|
||||||
|
markers[path] = SyncRootMarker(etag: root.etag, fullWalkAt: now())
|
||||||
|
} else {
|
||||||
|
markers[path] = nil
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
markers[path] = nil // cut short: make the next run walk it for real
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
// Couldn't reach the server for this path (or it answered with
|
||||||
|
// nothing usable): leave its state and marker alone and try again
|
||||||
|
// next run.
|
||||||
|
NSLog("[Sync] skipping %@ this run: %@", path, String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !summary.conflicts.isEmpty {
|
||||||
|
bus.addConflicts(accountId: accountId, summary.conflicts)
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the user's choice for one conflicted file - `local` uploads the
|
||||||
|
/// device's copy over the server's, `server` downloads the server's over
|
||||||
|
/// the device's - then refreshes the recorded etag so the next pass doesn't
|
||||||
|
/// immediately re-flag it. Returns whether it worked.
|
||||||
|
func resolveConflict(
|
||||||
|
_ conflict: SyncConflict, resolution: String, creds: SyncCredentials
|
||||||
|
) async -> Bool {
|
||||||
|
// An unreadable baseline must not be overwritten with a one-file one.
|
||||||
|
guard var state = try? store.loadState(accountId: conflict.accountId),
|
||||||
|
let local = LocalFS.resolve(conflict.relPath, in: store.syncRoot(accountId: conflict.accountId))
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
var ok = false
|
||||||
|
do {
|
||||||
|
switch resolution {
|
||||||
|
case "local":
|
||||||
|
if FileManager.default.fileExists(atPath: local.path) {
|
||||||
|
ok = try await client.upload(creds, remotePath: conflict.remotePath, from: local)
|
||||||
|
}
|
||||||
|
case "server":
|
||||||
|
ok = try await client.download(creds, remotePath: conflict.remotePath, to: local)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard ok else { return false }
|
||||||
|
|
||||||
|
let fresh = (try? await client.propfindSelf(creds, path: conflict.remotePath)) ?? nil
|
||||||
|
let stat = LocalFS.stat(local)
|
||||||
|
state[conflict.fileId] = SyncFileState(
|
||||||
|
relPath: conflict.relPath, etag: fresh?.etag ?? "", lastModified: fresh?.lastModified ?? 0,
|
||||||
|
size: stat?.size ?? 0, localMTime: stat?.mtimeMs ?? 0)
|
||||||
|
store.saveState(accountId: conflict.accountId, state)
|
||||||
|
bus.removeConflict(accountId: conflict.accountId, fileId: conflict.fileId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The on-device side of sync: the mirrored files and the per-account sync
|
||||||
|
/// state. Laid out under one base directory (Application Support, which is
|
||||||
|
/// also where Dart's `SyncService.baseDirectory()` looks for the mirror):
|
||||||
|
///
|
||||||
|
/// <base>/sync/<accountId>/... the mirrored files
|
||||||
|
/// <base>/sync-state/<accountId>/*.json state, root markers, missing roots
|
||||||
|
///
|
||||||
|
/// Plain JSON files rather than `UserDefaults`: the state map grows with the
|
||||||
|
/// number of synced files. Not thread-safe by itself - the sync runner holds
|
||||||
|
/// one lock around anything that reads-then-rewrites it (see `AsyncMutex`).
|
||||||
|
final class SyncStore {
|
||||||
|
/// 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. Same value as Android.
|
||||||
|
static let rootsVersion = 2
|
||||||
|
|
||||||
|
static let shared = SyncStore(base: defaultBase)
|
||||||
|
|
||||||
|
static var defaultBase: URL {
|
||||||
|
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
let base: URL
|
||||||
|
private let fileManager = FileManager.default
|
||||||
|
|
||||||
|
init(base: URL) {
|
||||||
|
self.base = base
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locations
|
||||||
|
|
||||||
|
/// The account's mirror folder, created on demand. Marked excluded from
|
||||||
|
/// iCloud/device backups: it's a re-downloadable copy of server data.
|
||||||
|
func syncRoot(accountId: String) -> URL {
|
||||||
|
let top = base.appendingPathComponent("sync", isDirectory: true)
|
||||||
|
let root = top.appendingPathComponent(accountId, isDirectory: true)
|
||||||
|
try? fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
var values = URLResourceValues()
|
||||||
|
values.isExcludedFromBackup = true
|
||||||
|
var topMutable = top
|
||||||
|
try? topMutable.setResourceValues(values)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stateDirectory(accountId: String) -> URL {
|
||||||
|
let dir = base.appendingPathComponent("sync-state", isDirectory: true)
|
||||||
|
.appendingPathComponent(accountId, isDirectory: true)
|
||||||
|
try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func file(_ accountId: String, _ name: String) -> URL {
|
||||||
|
stateDirectory(accountId: accountId).appendingPathComponent(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Per-file state
|
||||||
|
|
||||||
|
/// The recorded baseline - empty if nothing has been recorded yet, and
|
||||||
|
/// *throws* if there is a file that can't be read or decoded (see
|
||||||
|
/// `SyncStateUnreadable`).
|
||||||
|
func loadState(accountId: String) throws -> [String: SyncFileState] {
|
||||||
|
let url = file(accountId, "state.json")
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else { return [:] }
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([String: SyncFileState].self, from: Data(contentsOf: url))
|
||||||
|
} catch {
|
||||||
|
throw SyncStateUnreadable(accountId: accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// False if the state couldn't be written - callers must not then record
|
||||||
|
/// anything that assumes it was.
|
||||||
|
@discardableResult
|
||||||
|
func saveState(accountId: String, _ state: [String: SyncFileState]) -> Bool {
|
||||||
|
write(state, to: file(accountId, "state.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Root markers
|
||||||
|
|
||||||
|
private struct MarkersFile: Codable {
|
||||||
|
let version: Int
|
||||||
|
let markers: [String: SyncRootMarker]
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRootMarkers(accountId: String) -> [String: SyncRootMarker] {
|
||||||
|
guard let stored = read(MarkersFile.self, from: file(accountId, "roots.json")),
|
||||||
|
stored.version == Self.rootsVersion
|
||||||
|
else { return [:] }
|
||||||
|
return stored.markers
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveRootMarkers(accountId: String, _ markers: [String: SyncRootMarker]) -> Bool {
|
||||||
|
write(MarkersFile(version: Self.rootsVersion, markers: markers), to: file(accountId, "roots.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Roots the server says are gone
|
||||||
|
|
||||||
|
func loadMissingRoots(accountId: String) -> Set<String> {
|
||||||
|
Set(read([String].self, from: file(accountId, "missing.json")) ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRootMissing(accountId: String, path: String, missing: Bool) {
|
||||||
|
var current = loadMissingRoots(accountId: accountId)
|
||||||
|
let changed = missing ? current.insert(path).inserted : (current.remove(path) != nil)
|
||||||
|
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Removing a path's mirror
|
||||||
|
|
||||||
|
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
|
||||||
|
/// forgets its entries - called when the user turns sync off for that path.
|
||||||
|
/// Without clearing the state too, a later re-add would see the (now
|
||||||
|
/// missing) local file as "deleted, server unchanged" and not pull it back.
|
||||||
|
func removeLocalSync(accountId: String, path: String) {
|
||||||
|
var cleanPath = path.trimmingCharacters(in: .whitespaces)
|
||||||
|
if cleanPath.hasPrefix("/") { cleanPath.removeFirst() }
|
||||||
|
while cleanPath.hasSuffix("/") { cleanPath.removeLast() }
|
||||||
|
let prefix = cleanPath.isEmpty ? "" : cleanPath + "/"
|
||||||
|
|
||||||
|
// An unreadable baseline is left alone rather than overwritten with a
|
||||||
|
// partial one; the next run reports it and does nothing.
|
||||||
|
if var state = try? loadState(accountId: accountId) {
|
||||||
|
// The sync root itself ("/") covers every recorded file.
|
||||||
|
for (fileId, entry) in state
|
||||||
|
where cleanPath.isEmpty || entry.relPath == cleanPath || entry.relPath.hasPrefix(prefix) {
|
||||||
|
state[fileId] = nil
|
||||||
|
}
|
||||||
|
saveState(accountId: accountId, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRootMissing(accountId: accountId, path: "/" + cleanPath, missing: false)
|
||||||
|
setRootMissing(accountId: accountId, path: cleanPath, missing: 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.
|
||||||
|
let removedPath = "/" + cleanPath
|
||||||
|
var markers = loadRootMarkers(accountId: accountId)
|
||||||
|
let stale = markers.keys.filter { key in
|
||||||
|
var k = key
|
||||||
|
while k.hasSuffix("/") { k.removeLast() }
|
||||||
|
return k.isEmpty || removedPath == "/" || k == removedPath || k.hasPrefix(removedPath + "/")
|
||||||
|
|| removedPath.hasPrefix(k + "/")
|
||||||
|
}
|
||||||
|
if !stale.isEmpty {
|
||||||
|
stale.forEach { markers[$0] = nil }
|
||||||
|
saveRootMarkers(accountId: accountId, markers)
|
||||||
|
}
|
||||||
|
|
||||||
|
let root = syncRoot(accountId: accountId)
|
||||||
|
let target = cleanPath.isEmpty ? root : root.appendingPathComponent(cleanPath)
|
||||||
|
if fileManager.fileExists(atPath: target.path) { try? fileManager.removeItem(at: target) }
|
||||||
|
if cleanPath.isEmpty { _ = syncRoot(accountId: accountId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func read<T: Decodable>(_ type: T.Type, from url: URL) -> T? {
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func write<T: Encodable>(_ value: T, to url: URL) -> Bool {
|
||||||
|
guard let data = try? JSONEncoder().encode(value) else { return false }
|
||||||
|
do {
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
struct UploadFile {
|
||||||
|
let uri: String
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads and downloads on a background `URLSession`, so a transfer keeps
|
||||||
|
/// going - and finishes - after the app is suspended or closed. The iOS
|
||||||
|
/// counterpart of Android's `ShareUploadService`/`DownloadService`
|
||||||
|
/// foreground services, behind the same Dart channels (`NativeServices`).
|
||||||
|
///
|
||||||
|
/// Uploads are one `PUT` per file straight to the account's WebDAV root
|
||||||
|
/// (no chunking), from a staged copy in Caches - a background upload task
|
||||||
|
/// needs a file that stays put, and the picker's temp file may not. Downloads
|
||||||
|
/// land in `Documents/Downloads`, which is visible in the Files app
|
||||||
|
/// (`UIFileSharingEnabled` + `LSSupportsOpeningDocumentsInPlace`).
|
||||||
|
final class TransferManager: NSObject {
|
||||||
|
static let shared = TransferManager()
|
||||||
|
static let sessionIdentifier = "dev.ayushya.noo.transfers"
|
||||||
|
|
||||||
|
/// Called on the main thread when an upload batch ends with at least one
|
||||||
|
/// file uploaded: (remote folder, succeeded, failed).
|
||||||
|
var onUploadBatchFinished: ((String, Int, Int) -> Void)?
|
||||||
|
|
||||||
|
/// The system's completion handler from
|
||||||
|
/// `application(_:handleEventsForBackgroundURLSession:)`, called once the
|
||||||
|
/// session has delivered every pending event.
|
||||||
|
var backgroundCompletionHandler: (() -> Void)?
|
||||||
|
|
||||||
|
private lazy var session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.background(withIdentifier: Self.sessionIdentifier)
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
config.isDiscretionary = false
|
||||||
|
config.waitsForConnectivity = true
|
||||||
|
// Two accounts can share a server: authenticate by the request's own
|
||||||
|
// header only, never a cookie another account's request left behind.
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The session the Share Extension's uploads run on (see `ShareUpload`).
|
||||||
|
/// The extension is gone by the time they finish, so the app recreates it -
|
||||||
|
/// same identifier and shared container - to receive the results.
|
||||||
|
private lazy var shareSession = URLSession(
|
||||||
|
configuration: ShareUpload.configuration(), delegate: self, delegateQueue: nil)
|
||||||
|
|
||||||
|
/// Touch both sessions at launch so a system relaunch for finished
|
||||||
|
/// background transfers reconnects to them.
|
||||||
|
func reconnect() {
|
||||||
|
_ = session
|
||||||
|
_ = shareSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Starting transfers
|
||||||
|
|
||||||
|
func startUpload(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
authHeader: String,
|
||||||
|
files: [UploadFile],
|
||||||
|
remoteFolder: String
|
||||||
|
) throws {
|
||||||
|
guard !files.isEmpty else { throw TransferError(message: "No files to upload.") }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
let stageDir = try Self.stagingDirectory().appendingPathComponent(batch, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: stageDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
var tasks: [URLSessionUploadTask] = []
|
||||||
|
for (index, file) in files.enumerated() {
|
||||||
|
guard let source = URL(string: file.uri), source.isFileURL else {
|
||||||
|
throw TransferError(message: "Can't read \(file.name).")
|
||||||
|
}
|
||||||
|
let staged = stageDir.appendingPathComponent("\(index)-\(file.name)")
|
||||||
|
do {
|
||||||
|
try FileManager.default.copyItem(at: source, to: staged)
|
||||||
|
// A file the Share Extension left in the App Group is ours to clean
|
||||||
|
// up once it's staged; a picker's temp file isn't.
|
||||||
|
if source.path.contains("/\(SharedInbox.folderName)/") {
|
||||||
|
try? FileManager.default.removeItem(at: source)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw TransferError(message: "Can't read \(file.name): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
guard let url = WebDAV.fileURL(
|
||||||
|
serverUrl: serverUrl,
|
||||||
|
username: username,
|
||||||
|
remotePath: WebDAV.join(remoteFolder, file.name)
|
||||||
|
) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
if let modified = (try? FileManager.default.attributesOfItem(atPath: staged.path))?[.modificationDate] as? Date {
|
||||||
|
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||||
|
}
|
||||||
|
let task = session.uploadTask(with: request, fromFile: staged)
|
||||||
|
task.taskDescription = Self.encode(
|
||||||
|
TransferTaskInfo(
|
||||||
|
kind: .upload, batch: batch, name: file.name,
|
||||||
|
remoteFolder: remoteFolder, stagedPath: staged.path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch
|
||||||
|
)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDownload(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
authHeader: String,
|
||||||
|
items: [(path: String, name: String)]
|
||||||
|
) throws {
|
||||||
|
guard !items.isEmpty else { throw TransferError(message: "No files to download.") }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
var tasks: [URLSessionDownloadTask] = []
|
||||||
|
for item in items {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: serverUrl, username: username, remotePath: item.path) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.setValue(authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
let task = session.downloadTask(with: request)
|
||||||
|
task.taskDescription = Self.encode(
|
||||||
|
TransferTaskInfo(kind: .download, batch: batch, name: item.name, remoteFolder: nil, stagedPath: nil)
|
||||||
|
)
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .download, remoteFolder: nil, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch
|
||||||
|
)
|
||||||
|
TransferNotifications.started(kind: .download, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Locations
|
||||||
|
|
||||||
|
private static func stagingDirectory() throws -> URL {
|
||||||
|
let caches = try FileManager.default.url(
|
||||||
|
for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
||||||
|
)
|
||||||
|
return caches.appendingPathComponent("uploads", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func downloadsDirectory() throws -> URL {
|
||||||
|
let documents = try FileManager.default.url(
|
||||||
|
for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true
|
||||||
|
)
|
||||||
|
let dir = documents.appendingPathComponent("Downloads", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Batch bookkeeping
|
||||||
|
|
||||||
|
private static func encode(_ info: TransferTaskInfo) -> String? {
|
||||||
|
(try? JSONEncoder().encode(info)).flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func info(for task: URLSessionTask) -> TransferTaskInfo? {
|
||||||
|
task.taskDescription
|
||||||
|
.flatMap { $0.data(using: .utf8) }
|
||||||
|
.flatMap { try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one file's outcome; fires the batch summary when it was the last.
|
||||||
|
private func record(_ info: TransferTaskInfo, success: Bool) {
|
||||||
|
if let batch = TransferBatchStore.record(info, success: success) { finish(batch, id: info.batch) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finish(_ batch: TransferBatch, id: String) {
|
||||||
|
TransferNotifications.finished(batch, id: id)
|
||||||
|
reportToDart(batch)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells Dart (so `FilesController` can refresh the folder) once an upload
|
||||||
|
/// batch has landed at least one file.
|
||||||
|
private func reportToDart(_ batch: TransferBatch) {
|
||||||
|
if batch.kind == .upload, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
|
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, batch.succeeded, batch.failed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folders the Share Extension finished uploading into while the app wasn't
|
||||||
|
/// running - call when the app becomes active.
|
||||||
|
func deliverExtensionUploads() {
|
||||||
|
for folder in TransferBatchStore.consumeFinishedUploadFolders() {
|
||||||
|
DispatchQueue.main.async { self.onUploadBatchFinished?(folder, 1, 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URLSession delegates
|
||||||
|
|
||||||
|
extension TransferManager: URLSessionDownloadDelegate {
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL
|
||||||
|
) {
|
||||||
|
guard let info = Self.info(for: downloadTask) else { return }
|
||||||
|
let status = (downloadTask.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
guard (200..<300).contains(status) else {
|
||||||
|
record(info, success: false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let destination = LocalFiles.uniqueURL(in: try Self.downloadsDirectory(), name: info.name)
|
||||||
|
try FileManager.default.moveItem(at: location, to: destination)
|
||||||
|
record(info, success: true)
|
||||||
|
} catch {
|
||||||
|
record(info, success: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let info = Self.info(for: task) else { return }
|
||||||
|
switch info.kind {
|
||||||
|
case .upload:
|
||||||
|
if let result = UploadResults.handle(task, error: error), let batch = result.batch {
|
||||||
|
reportToDart(batch)
|
||||||
|
}
|
||||||
|
case .download:
|
||||||
|
// A finished download was already counted in didFinishDownloadingTo;
|
||||||
|
// only a transport failure arrives here.
|
||||||
|
if error != nil { record(info, success: false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.backgroundCompletionHandler?()
|
||||||
|
self.backgroundCompletionHandler = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.ayushya.noo</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,11 +2,338 @@ import Flutter
|
|||||||
import UIKit
|
import UIKit
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Runner
|
||||||
|
|
||||||
class RunnerTests: XCTestCase {
|
class RunnerTests: XCTestCase {
|
||||||
|
|
||||||
func testExample() {
|
func testFileURLEncodesEachSegment() {
|
||||||
// If you add code to the Runner application, consider adding tests here.
|
let url = WebDAV.fileURL(
|
||||||
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
serverUrl: "https://cloud.example.com", username: "alice",
|
||||||
|
remotePath: "/Tax Forms/2026 #1/a&b.pdf")
|
||||||
|
XCTAssertEqual(
|
||||||
|
url?.absoluteString,
|
||||||
|
"https://cloud.example.com/remote.php/dav/files/alice/Tax%20Forms/2026%20%231/a&b.pdf")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFileURLKeepsServerSubPathAndDropsTrailingSlash() {
|
||||||
|
let url = WebDAV.fileURL(
|
||||||
|
serverUrl: "https://example.com/nextcloud/", username: "bob@home", remotePath: "docs/x.txt")
|
||||||
|
XCTAssertEqual(
|
||||||
|
url?.absoluteString,
|
||||||
|
"https://example.com/nextcloud/remote.php/dav/files/bob@home/docs/x.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileURLForRoot() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
WebDAV.fileURL(serverUrl: "https://c.io", username: "u", remotePath: "/")?.absoluteString,
|
||||||
|
"https://c.io/remote.php/dav/files/u")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJoinUsesOneSlash() {
|
||||||
|
XCTAssertEqual(WebDAV.join("/", "a.txt"), "/a.txt")
|
||||||
|
XCTAssertEqual(WebDAV.join("/docs", "a.txt"), "/docs/a.txt")
|
||||||
|
XCTAssertEqual(WebDAV.join("/docs/", "a.txt"), "/docs/a.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUniqueURLNumbersCollisions() throws {
|
||||||
|
let dir = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("noo-tests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: dir) }
|
||||||
|
|
||||||
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a.txt")
|
||||||
|
FileManager.default.createFile(atPath: dir.appendingPathComponent("a.txt").path, contents: nil)
|
||||||
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (1).txt")
|
||||||
|
FileManager.default.createFile(atPath: dir.appendingPathComponent("a (1).txt").path, contents: nil)
|
||||||
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "a.txt").lastPathComponent, "a (2).txt")
|
||||||
|
FileManager.default.createFile(atPath: dir.appendingPathComponent("README").path, contents: nil)
|
||||||
|
XCTAssertEqual(LocalFiles.uniqueURL(in: dir, name: "README").lastPathComponent, "README (1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SharedInbox (Share Extension hand-off)
|
||||||
|
|
||||||
|
private func makeRoot() throws -> URL {
|
||||||
|
let root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("noo-inbox-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
addTeardownBlock { try? FileManager.default.removeItem(at: root) }
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeSharedFile(in root: URL, name: String, contents: String = "hi") throws -> SharedItem {
|
||||||
|
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||||
|
let file = batch.appendingPathComponent(name)
|
||||||
|
try contents.write(to: file, atomically: true, encoding: .utf8)
|
||||||
|
return SharedItem(path: file.path, name: name, mimeType: "text/plain", size: Int64(contents.utf8.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInboxConsumeReturnsAppendedItemsOnceThenNothing() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
let a = try makeSharedFile(in: root, name: "a.txt")
|
||||||
|
let b = try makeSharedFile(in: root, name: "b.txt")
|
||||||
|
try SharedInbox.append([a], in: root)
|
||||||
|
try SharedInbox.append([b], in: root)
|
||||||
|
|
||||||
|
XCTAssertEqual(SharedInbox.consume(in: root), [a, b])
|
||||||
|
XCTAssertEqual(SharedInbox.consume(in: root), [], "each share is delivered once")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInboxConsumeDropsFilesThatNoLongerExist() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
let kept = try makeSharedFile(in: root, name: "kept.txt")
|
||||||
|
let gone = try makeSharedFile(in: root, name: "gone.txt")
|
||||||
|
try SharedInbox.append([kept, gone], in: root)
|
||||||
|
try FileManager.default.removeItem(atPath: gone.path)
|
||||||
|
|
||||||
|
XCTAssertEqual(SharedInbox.consume(in: root), [kept])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInboxConsumeWithNothingPendingIsEmpty() throws {
|
||||||
|
XCTAssertEqual(SharedInbox.consume(in: try makeRoot()), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInboxAppendOfNothingCreatesNoManifest() throws {
|
||||||
|
let root = try makeRoot()
|
||||||
|
try SharedInbox.append([], in: root)
|
||||||
|
XCTAssertEqual(SharedInbox.consume(in: root), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DavFolderParser (Share Extension folder picker)
|
||||||
|
|
||||||
|
private func multistatus(_ responses: [(href: String, collection: Bool)]) -> Data {
|
||||||
|
let body = responses.map { r in
|
||||||
|
"""
|
||||||
|
<d:response><d:href>\(r.href)</d:href><d:propstat><d:prop>\
|
||||||
|
<d:resourcetype>\(r.collection ? "<d:collection/>" : "")</d:resourcetype>\
|
||||||
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
"""
|
||||||
|
}.joined()
|
||||||
|
return Data(
|
||||||
|
"<?xml version=\"1.0\"?><d:multistatus xmlns:d=\"DAV:\">\(body)</d:multistatus>".utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFoldersListsOnlyChildFoldersSortedWithoutHidden() {
|
||||||
|
let xml = multistatus([
|
||||||
|
("/remote.php/dav/files/alice/Docs/", true),
|
||||||
|
("/remote.php/dav/files/alice/Docs/zeta/", true),
|
||||||
|
("/remote.php/dav/files/alice/Docs/Tax%20Forms/", true),
|
||||||
|
("/remote.php/dav/files/alice/Docs/note.txt", false),
|
||||||
|
("/remote.php/dav/files/alice/Docs/.git/", true),
|
||||||
|
])
|
||||||
|
XCTAssertEqual(
|
||||||
|
DavFolderParser.folders(from: xml, excluding: "/Docs"),
|
||||||
|
[DavFolder(name: "Tax Forms", path: "/Docs/Tax Forms"), DavFolder(name: "zeta", path: "/Docs/zeta")])
|
||||||
|
XCTAssertEqual(
|
||||||
|
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .include).map(\.name),
|
||||||
|
[".git", "Tax Forms", "zeta"])
|
||||||
|
XCTAssertEqual(
|
||||||
|
DavFolderParser.folders(from: xml, excluding: "/Docs", hidden: .only).map(\.name),
|
||||||
|
[".git"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHiddenFilterTreatsAnyDotSegmentAsHidden() {
|
||||||
|
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache"))
|
||||||
|
XCTAssertTrue(HiddenFilter.isHidden(path: "/.cache/inside/deeper"), "children of a hidden folder are hidden")
|
||||||
|
XCTAssertFalse(HiddenFilter.isHidden(path: "/Docs/Tax Forms"))
|
||||||
|
XCTAssertTrue(HiddenFilter.hide.shows(path: "/Docs"))
|
||||||
|
XCTAssertFalse(HiddenFilter.hide.shows(path: "/.git"))
|
||||||
|
XCTAssertTrue(HiddenFilter.only.shows(path: "/.git/hooks"))
|
||||||
|
XCTAssertFalse(HiddenFilter.only.shows(path: "/Docs"))
|
||||||
|
XCTAssertTrue(HiddenFilter.include.shows(path: "/Docs"))
|
||||||
|
XCTAssertEqual(HiddenFilter(raw: "include"), .include)
|
||||||
|
XCTAssertEqual(HiddenFilter(raw: "nonsense"), .hide, "unknown values fall back to hiding")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFoldersAtRootAndUnderServerSubPath() {
|
||||||
|
let xml = multistatus([
|
||||||
|
("/nextcloud/remote.php/dav/files/bob/", true),
|
||||||
|
("/nextcloud/remote.php/dav/files/bob/Photos/", true),
|
||||||
|
])
|
||||||
|
XCTAssertEqual(
|
||||||
|
DavFolderParser.folders(from: xml, excluding: "/"),
|
||||||
|
[DavFolder(name: "Photos", path: "/Photos")])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRelativePath() {
|
||||||
|
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/B%20C/D/"), "/B C/D")
|
||||||
|
XCTAssertEqual(DavFolderParser.relativePath(fromHref: "/remote.php/dav/files/a/"), "/")
|
||||||
|
XCTAssertNil(DavFolderParser.relativePath(fromHref: "/somewhere/else"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFoldersIgnoresGarbage() {
|
||||||
|
XCTAssertEqual(DavFolderParser.folders(from: Data("not xml".utf8), excluding: "/"), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared account (Keychain group shared with the extension)
|
||||||
|
|
||||||
|
private func account(_ id: String, hidden: String = "hide") -> SharedAccount {
|
||||||
|
SharedAccount(
|
||||||
|
id: id, serverUrl: "https://\(id).example.com", username: id,
|
||||||
|
authHeader: "Basic \(id)", displayName: "\(id)@\(id).example.com", hiddenFilter: hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSharedAccountsRoundTripThroughTheKeychain() throws {
|
||||||
|
let accounts = SharedAccounts(
|
||||||
|
accounts: [account("alice", hidden: "include"), account("bob")], activeId: "bob",
|
||||||
|
loginLockEnabled: true, lockAccountSwitching: true, lockHiddenFiles: false)
|
||||||
|
addTeardownBlock { SharedAccountStore.clear() }
|
||||||
|
|
||||||
|
SharedAccountStore.clear()
|
||||||
|
XCTAssertNil(SharedAccountStore.load())
|
||||||
|
try SharedAccountStore.save(accounts)
|
||||||
|
XCTAssertEqual(SharedAccountStore.load(), accounts)
|
||||||
|
|
||||||
|
let replacement = SharedAccounts(
|
||||||
|
accounts: [account("carol")], activeId: "carol",
|
||||||
|
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||||
|
try SharedAccountStore.save(replacement)
|
||||||
|
XCTAssertEqual(SharedAccountStore.load(), replacement, "saving replaces, never duplicates")
|
||||||
|
|
||||||
|
SharedAccountStore.clear()
|
||||||
|
XCTAssertNil(SharedAccountStore.load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSharedAccountsActiveFallsBackToTheFirstAccount() {
|
||||||
|
var shared = SharedAccounts(
|
||||||
|
accounts: [account("alice"), account("bob")], activeId: "bob",
|
||||||
|
loginLockEnabled: false, lockAccountSwitching: false, lockHiddenFiles: false)
|
||||||
|
XCTAssertEqual(shared.active?.id, "bob")
|
||||||
|
shared.activeId = "gone"
|
||||||
|
XCTAssertEqual(shared.active?.id, "alice")
|
||||||
|
shared.activeId = nil
|
||||||
|
XCTAssertEqual(shared.active?.id, "alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnlockRulesAreIndependentOfLoginLock() {
|
||||||
|
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
||||||
|
let shared = SharedAccounts(
|
||||||
|
accounts: [account("a")], activeId: "a",
|
||||||
|
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||||
|
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false))
|
||||||
|
XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true))
|
||||||
|
XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false))
|
||||||
|
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which actions in the share sheet ask to unlock - and how many prompts.
|
||||||
|
|
||||||
|
private func lockedAccounts(switching: Bool, hidden: Bool) -> SharedAccounts {
|
||||||
|
SharedAccounts(
|
||||||
|
accounts: [account("alice"), account("bob")], activeId: "alice",
|
||||||
|
loginLockEnabled: false, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChoosingTheActiveAccountWithoutHiddenFoldersNeverPrompts() {
|
||||||
|
let shared = lockedAccounts(switching: true, hidden: true)
|
||||||
|
let needs = shared.unlockNeeds(choosing: account("alice"), showing: .hide)
|
||||||
|
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: false))
|
||||||
|
XCTAssertFalse(needs.needsPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChoosingAnotherAccountPromptsEveryTime() {
|
||||||
|
let shared = lockedAccounts(switching: true, hidden: false)
|
||||||
|
// No unlock is remembered: the same choice asks again on every call.
|
||||||
|
for _ in 0..<3 {
|
||||||
|
XCTAssertTrue(shared.unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
|
||||||
|
}
|
||||||
|
XCTAssertFalse(
|
||||||
|
lockedAccounts(switching: false, hidden: false).unlockNeeds(choosing: account("bob"), showing: .hide).needsPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSwitchPlusHiddenFoldersIsOnePromptThatCoversBoth() {
|
||||||
|
let needs = lockedAccounts(switching: true, hidden: true)
|
||||||
|
.unlockNeeds(choosing: account("bob"), showing: .include)
|
||||||
|
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: true, showsHidden: true))
|
||||||
|
XCTAssertTrue(needs.needsPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHiddenFoldersAloneOnTheActiveAccountPromptsForHidden() {
|
||||||
|
let needs = lockedAccounts(switching: true, hidden: true)
|
||||||
|
.unlockNeeds(choosing: account("alice"), showing: .only)
|
||||||
|
XCTAssertEqual(needs, SelectionUnlock(switchesAccount: false, showsHidden: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnlyTurningHiddenFoldersOnAsksToUnlock() {
|
||||||
|
let locked = lockedAccounts(switching: false, hidden: true)
|
||||||
|
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .only))
|
||||||
|
XCTAssertTrue(locked.needsUnlockToChangeHidden(from: .hide, to: .include))
|
||||||
|
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .include, to: .hide), "hiding again never asks")
|
||||||
|
XCTAssertFalse(locked.needsUnlockToChangeHidden(from: .only, to: .include), "only <-> all never asks")
|
||||||
|
XCTAssertFalse(
|
||||||
|
lockedAccounts(switching: false, hidden: false).needsUnlockToChangeHidden(from: .hide, to: .include),
|
||||||
|
"no lock set, no prompt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - TransferBatchStore
|
||||||
|
|
||||||
|
func testBatchSummaryFiresOnlyOnTheLastFile() {
|
||||||
|
let batchId = UUID().uuidString
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .upload, remoteFolder: "/Docs", total: 3, succeeded: 0, failed: 0), id: batchId)
|
||||||
|
let info = TransferTaskInfo(kind: .upload, batch: batchId, name: "a", remoteFolder: "/Docs", stagedPath: nil)
|
||||||
|
|
||||||
|
XCTAssertNil(TransferBatchStore.record(info, success: true))
|
||||||
|
XCTAssertNil(TransferBatchStore.record(info, success: false))
|
||||||
|
let finished = TransferBatchStore.record(info, success: true)
|
||||||
|
XCTAssertEqual(finished?.succeeded, 2)
|
||||||
|
XCTAssertEqual(finished?.failed, 1)
|
||||||
|
XCTAssertEqual(finished?.remoteFolder, "/Docs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFinishedUploadFoldersAreNotedOnceAndConsumed() {
|
||||||
|
_ = TransferBatchStore.consumeFinishedUploadFolders() // start clean
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: "/Docs")
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: "/Photos")
|
||||||
|
|
||||||
|
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), ["/Docs", "/Photos"])
|
||||||
|
XCTAssertEqual(TransferBatchStore.consumeFinishedUploadFolders(), [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNotificationFileCountWording() {
|
||||||
|
XCTAssertEqual(TransferNotifications.files(1), "1 file")
|
||||||
|
XCTAssertEqual(TransferNotifications.files(3), "3 files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - External storage + tolerant decoding
|
||||||
|
|
||||||
|
func testFoldersFlagExternalMountsAndStorageFilterAppliesToThem() {
|
||||||
|
let xml = Data(
|
||||||
|
"""
|
||||||
|
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response><d:href>/remote.php/dav/files/a/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/remote.php/dav/files/a/NAS/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>external</nc:mount-type>
|
||||||
|
</d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/remote.php/dav/files/a/Docs/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/remote.php/dav/files/a/Shared/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype><nc:mount-type>shared</nc:mount-type>
|
||||||
|
</d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""".utf8)
|
||||||
|
let folders = DavFolderParser.folders(from: xml, excluding: "/")
|
||||||
|
XCTAssertEqual(folders.map(\.name), ["Docs", "NAS", "Shared"])
|
||||||
|
XCTAssertEqual(folders.map(\.isExternal), [false, true, false], "only mount-type=external counts")
|
||||||
|
|
||||||
|
XCTAssertTrue(StorageFilter.cloud.shows(isExternal: false))
|
||||||
|
XCTAssertFalse(StorageFilter.cloud.shows(isExternal: true))
|
||||||
|
XCTAssertTrue(StorageFilter.external.shows(isExternal: true))
|
||||||
|
XCTAssertFalse(StorageFilter.external.shows(isExternal: false))
|
||||||
|
XCTAssertTrue(StorageFilter.all.shows(isExternal: true) && StorageFilter.all.shows(isExternal: false))
|
||||||
|
XCTAssertEqual(StorageFilter(raw: "bogus"), .cloud)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSharedAccountDecodesDataWrittenBeforeNewFieldsExisted() throws {
|
||||||
|
let old = Data(
|
||||||
|
"""
|
||||||
|
{"id":"a","serverUrl":"https://x","username":"u","authHeader":"Basic x","displayName":"u@x"}
|
||||||
|
""".utf8)
|
||||||
|
let decoded = try JSONDecoder().decode(SharedAccount.self, from: old)
|
||||||
|
XCTAssertEqual(decoded.hiddenFilter, "hide")
|
||||||
|
XCTAssertEqual(decoded.storageScope, "cloud")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,875 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Runner
|
||||||
|
|
||||||
|
/// An in-memory Nextcloud that answers just enough WebDAV (PROPFIND depth 0/1,
|
||||||
|
/// GET, PUT) for the sync engine to run end to end against - with ancestor
|
||||||
|
/// etags bumping on every change, like the real thing.
|
||||||
|
final class FakeDav: URLProtocol {
|
||||||
|
struct Node {
|
||||||
|
var isFolder: Bool
|
||||||
|
var data = Data()
|
||||||
|
var etag: String
|
||||||
|
var fileId: String
|
||||||
|
var mtime = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var nodes: [String: Node] = [:]
|
||||||
|
static var failAll = false
|
||||||
|
/// A folder whose PROPFIND answers 500 while everything else works - for
|
||||||
|
/// proving a half-built manifest is never trusted.
|
||||||
|
static var failPropfindPath: String?
|
||||||
|
/// Answer PROPFIND with a 207 whose XML is cut off mid-way.
|
||||||
|
static var truncatePropfind = false
|
||||||
|
static var log: [String] = []
|
||||||
|
private static var counter = 0
|
||||||
|
static let user = "alice"
|
||||||
|
private static let base = "/remote.php/dav/files/alice"
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
nodes = ["/": Node(isFolder: true, etag: nextTag(), fileId: "root")]
|
||||||
|
failAll = false
|
||||||
|
failPropfindPath = nil
|
||||||
|
truncatePropfind = false
|
||||||
|
log = []
|
||||||
|
counter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nextTag() -> String {
|
||||||
|
counter += 1
|
||||||
|
return "tag\(counter)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parent(of path: String) -> String {
|
||||||
|
let p = (path as NSString).deletingLastPathComponent
|
||||||
|
return p.isEmpty ? "/" : p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bumps [path]'s etag and every ancestor's.
|
||||||
|
private static func touch(_ path: String) {
|
||||||
|
var p = path
|
||||||
|
while true {
|
||||||
|
nodes[p]?.etag = nextTag()
|
||||||
|
if p == "/" { break }
|
||||||
|
p = parent(of: p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Test-side helpers
|
||||||
|
|
||||||
|
static func mkdir(_ path: String) {
|
||||||
|
var parts: [String] = []
|
||||||
|
for comp in path.split(separator: "/") {
|
||||||
|
parts.append(String(comp))
|
||||||
|
let p = "/" + parts.joined(separator: "/")
|
||||||
|
if nodes[p] == nil {
|
||||||
|
nodes[p] = Node(isFolder: true, etag: nextTag(), fileId: "id\(counter)")
|
||||||
|
touch(parent(of: p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func put(_ path: String, _ text: String) {
|
||||||
|
mkdir(parent(of: path))
|
||||||
|
var node = nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(nextTag())")
|
||||||
|
node.data = Data(text.utf8)
|
||||||
|
nodes[path] = node
|
||||||
|
touch(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remove(_ path: String) {
|
||||||
|
for key in nodes.keys where key == path || key.hasPrefix(path + "/") { nodes[key] = nil }
|
||||||
|
touch(parent(of: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func text(_ path: String) -> String? {
|
||||||
|
nodes[path].flatMap { String(data: $0.data, encoding: .utf8) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: URLProtocol
|
||||||
|
|
||||||
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||||
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||||
|
override func stopLoading() {}
|
||||||
|
|
||||||
|
override func startLoading() {
|
||||||
|
let request = self.request
|
||||||
|
let method = request.httpMethod ?? "GET"
|
||||||
|
let depth = request.value(forHTTPHeaderField: "Depth") ?? "-"
|
||||||
|
var path = (request.url?.path ?? "").removingPercentEncoding ?? ""
|
||||||
|
if path.hasPrefix(Self.base) { path = String(path.dropFirst(Self.base.count)) }
|
||||||
|
while path.count > 1 && path.hasSuffix("/") { path.removeLast() }
|
||||||
|
if path.isEmpty { path = "/" }
|
||||||
|
Self.log.append("\(method) \(depth) \(path)")
|
||||||
|
|
||||||
|
func send(_ status: Int, _ body: Data = Data()) {
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||||
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
|
client?.urlProtocol(self, didLoad: body)
|
||||||
|
client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self.failAll { return send(503) }
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "PROPFIND":
|
||||||
|
if path == Self.failPropfindPath { return send(500) }
|
||||||
|
guard Self.nodes[path] != nil else { return send(404) }
|
||||||
|
var paths = [path]
|
||||||
|
if depth == "1" {
|
||||||
|
paths += Self.nodes.keys.filter { $0 != path && Self.parent(of: $0) == path }.sorted()
|
||||||
|
}
|
||||||
|
var body = Self.multistatus(paths)
|
||||||
|
// Only folder listings: a cut-off *listing* still holds a few complete
|
||||||
|
// responses, which is the dangerous case (a partial manifest).
|
||||||
|
if Self.truncatePropfind && depth == "1" { body = String(body.prefix(body.count * 2 / 3)) }
|
||||||
|
send(207, Data(body.utf8))
|
||||||
|
case "GET":
|
||||||
|
guard let node = Self.nodes[path], !node.isFolder else { return send(404) }
|
||||||
|
send(200, node.data)
|
||||||
|
case "PUT":
|
||||||
|
var data = Data()
|
||||||
|
if let stream = request.httpBodyStream {
|
||||||
|
stream.open()
|
||||||
|
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||||
|
while stream.hasBytesAvailable {
|
||||||
|
let n = stream.read(&buffer, maxLength: buffer.count)
|
||||||
|
if n <= 0 { break }
|
||||||
|
data.append(buffer, count: n)
|
||||||
|
}
|
||||||
|
stream.close()
|
||||||
|
} else if let body = request.httpBody {
|
||||||
|
data = body
|
||||||
|
}
|
||||||
|
Self.mkdir(Self.parent(of: path))
|
||||||
|
var node = Self.nodes[path] ?? Node(isFolder: false, etag: "", fileId: "id\(Self.nextTag())")
|
||||||
|
node.data = data
|
||||||
|
Self.nodes[path] = node
|
||||||
|
Self.touch(path)
|
||||||
|
send(201)
|
||||||
|
default:
|
||||||
|
send(405)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let httpDate: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static func multistatus(_ paths: [String]) -> String {
|
||||||
|
let responses = paths.map { p -> String in
|
||||||
|
let node = nodes[p]!
|
||||||
|
let encoded = p.split(separator: "/").map { WebDAV.encodeSegment(String($0)) }.joined(separator: "/")
|
||||||
|
let href = base + (encoded.isEmpty ? "" : "/" + encoded) + (node.isFolder ? "/" : "")
|
||||||
|
return """
|
||||||
|
<d:response><d:href>\(href)</d:href><d:propstat><d:prop>\
|
||||||
|
<d:getlastmodified>\(httpDate.string(from: node.mtime))</d:getlastmodified>\
|
||||||
|
<d:getcontentlength>\(node.data.count)</d:getcontentlength>\
|
||||||
|
<d:resourcetype>\(node.isFolder ? "<d:collection/>" : "")</d:resourcetype>\
|
||||||
|
<d:getetag>"\(node.etag)"</d:getetag><oc:fileid>\(node.fileId)</oc:fileid>\
|
||||||
|
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||||
|
"""
|
||||||
|
}.joined()
|
||||||
|
return """
|
||||||
|
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">\(responses)</d:multistatus>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SyncEngineTests: XCTestCase {
|
||||||
|
private var store: SyncStore!
|
||||||
|
private var bus: SyncStatusBus!
|
||||||
|
private var runner: SyncRunner!
|
||||||
|
private var fakeSession: URLSession!
|
||||||
|
private var clock: Int64 = 1_700_000_000_000
|
||||||
|
private let config = SyncAccountConfig(
|
||||||
|
accountId: "acct", serverUrl: "https://dav.test", username: "alice", authHeader: "Basic x",
|
||||||
|
folders: ["/Docs"], wifiOnly: false, intervalMinutes: nil, notify: false)
|
||||||
|
private var creds: SyncCredentials {
|
||||||
|
SyncCredentials(serverUrl: config.serverUrl, username: config.username, authHeader: config.authHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
FakeDav.reset()
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("noo-sync-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
||||||
|
addTeardownBlock { try? FileManager.default.removeItem(at: base) }
|
||||||
|
store = SyncStore(base: base)
|
||||||
|
bus = SyncStatusBus()
|
||||||
|
|
||||||
|
let sessionConfig = URLSessionConfiguration.ephemeral
|
||||||
|
sessionConfig.protocolClasses = [FakeDav.self]
|
||||||
|
fakeSession = URLSession(configuration: sessionConfig)
|
||||||
|
runner = SyncRunner(
|
||||||
|
client: DavSyncClient(session: fakeSession),
|
||||||
|
store: store, bus: bus, now: { [unowned self] in self.clock })
|
||||||
|
}
|
||||||
|
|
||||||
|
private func state() -> [String: SyncFileState] {
|
||||||
|
(try? store.loadState(accountId: "acct")) ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mirror: URL { store.syncRoot(accountId: "acct") }
|
||||||
|
private func local(_ rel: String) -> String? {
|
||||||
|
try? String(contentsOf: mirror.appendingPathComponent(rel), encoding: .utf8)
|
||||||
|
}
|
||||||
|
private func exists(_ rel: String) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: mirror.appendingPathComponent(rel).path)
|
||||||
|
}
|
||||||
|
private func editLocally(_ rel: String, _ text: String) throws {
|
||||||
|
let url = mirror.appendingPathComponent(rel)
|
||||||
|
try text.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes([.modificationDate: Date().addingTimeInterval(60)], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
private func depth1Requests() -> Int { FakeDav.log.filter { $0.hasPrefix("PROPFIND 1") }.count }
|
||||||
|
|
||||||
|
private func seedServer() {
|
||||||
|
FakeDav.put("/Docs/a.txt", "alpha")
|
||||||
|
FakeDav.put("/Docs/sub/b.txt", "bravo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The happy path
|
||||||
|
|
||||||
|
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
|
||||||
|
seedServer()
|
||||||
|
FakeDav.mkdir("/Docs/empty")
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
|
||||||
|
XCTAssertEqual(summary.downloaded, 2)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertTrue(exists("Docs/empty"), "empty server folders are mirrored")
|
||||||
|
XCTAssertEqual(state().count, 2)
|
||||||
|
XCTAssertNotNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
|
||||||
|
XCTAssertFalse(bus.snapshot().syncing, "announced as finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnchangedServerIsOneCheapRequestNotAWalk() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
let walks = depth1Requests()
|
||||||
|
|
||||||
|
FakeDav.log = []
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(summary.changedAnything)
|
||||||
|
XCTAssertEqual(depth1Requests(), 0, "the root etag shortcut skipped the walk (was \(walks) before)")
|
||||||
|
XCTAssertEqual(FakeDav.log, ["PROPFIND 0 /Docs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testForceAlwaysWalks() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.log = []
|
||||||
|
_ = await runner.run(config, force: true)
|
||||||
|
XCTAssertGreaterThan(depth1Requests(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShortcutExpiresAfterTheMaxAge() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
clock += SyncRunner.fullWalkMaxAgeMs + 1
|
||||||
|
FakeDav.log = []
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
XCTAssertGreaterThan(depth1Requests(), 0, "etags aren't trusted forever")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerChangeIsDownloaded() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "alpha v2")
|
||||||
|
FakeDav.put("/Docs/new.txt", "fresh")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 2)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha v2")
|
||||||
|
XCTAssertEqual(local("Docs/new.txt"), "fresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLocalEditIsUploadedOnceAndNotReDownloaded() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try editLocally("Docs/a.txt", "alpha edited on phone")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.uploaded, 1)
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "alpha edited on phone")
|
||||||
|
|
||||||
|
let again = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(again.changedAnything, "our own upload must not come back as a server change")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha edited on phone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFileDeletedLocallyIsPulledBack() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try FileManager.default.removeItem(at: mirror.appendingPathComponent("Docs/a.txt"))
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertNotNil(FakeDav.text("/Docs/a.txt"), "a local delete never deletes on the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conflicts
|
||||||
|
|
||||||
|
func testChangedOnBothSidesIsAConflictAndNothingIsOverwritten() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.conflicts.map(\.relPath), ["Docs/a.txt"])
|
||||||
|
XCTAssertEqual(summary.conflicts.first?.name, "a.txt")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "phone edit", "the device copy is untouched")
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit", "the server copy is untouched")
|
||||||
|
XCTAssertEqual(bus.snapshot().conflicts.count, 1)
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an unresolved conflict forces a real walk next time")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveWithServerCopyThenNoConflictOnTheNextPass() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||||
|
|
||||||
|
let ok = await runner.resolveConflict(conflict, resolution: "server", creds: creds)
|
||||||
|
XCTAssertTrue(ok)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "server edit")
|
||||||
|
XCTAssertTrue(bus.snapshot().conflicts.isEmpty)
|
||||||
|
|
||||||
|
let next = await runner.run(config, force: false)
|
||||||
|
XCTAssertTrue(next.conflicts.isEmpty)
|
||||||
|
XCTAssertFalse(next.changedAnything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveWithLocalCopyUploadsIt() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||||
|
|
||||||
|
let ok = await runner.resolveConflict(conflict, resolution: "local", creds: creds)
|
||||||
|
XCTAssertTrue(ok)
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "phone edit")
|
||||||
|
let next = await runner.run(config, force: false)
|
||||||
|
XCTAssertTrue(next.conflicts.isEmpty && !next.changedAnything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUnknownResolutionDoesNothing() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.put("/Docs/a.txt", "server edit")
|
||||||
|
try editLocally("Docs/a.txt", "phone edit")
|
||||||
|
let conflict = await runner.run(config, force: false).conflicts[0]
|
||||||
|
|
||||||
|
let ok = await runner.resolveConflict(conflict, resolution: "bogus", creds: creds)
|
||||||
|
XCTAssertFalse(ok)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "phone edit")
|
||||||
|
XCTAssertEqual(FakeDav.text("/Docs/a.txt"), "server edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Deletes (the dangerous part)
|
||||||
|
|
||||||
|
func testFileDeletedOnTheServerIsRemovedLocally() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.remove("/Docs/a.txt")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
XCTAssertTrue(exists("Docs/sub/b.txt"), "only the deleted file goes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFolderDeletedOnTheServerRemovesItsFilesAndTheEmptyDirectory() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.remove("/Docs/sub")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("Docs/sub/b.txt"))
|
||||||
|
XCTAssertFalse(exists("Docs/sub"), "the now-empty directory is pruned")
|
||||||
|
XCTAssertTrue(exists("Docs/a.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUnreachableServerNeverDeletesAnything() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
let before = state()
|
||||||
|
|
||||||
|
FakeDav.failAll = true
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertFalse(summary.changedAnything, "a 503 is not an empty folder")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertEqual(state(), before)
|
||||||
|
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "unreachable is not 'gone'")
|
||||||
|
|
||||||
|
FakeDav.failAll = false
|
||||||
|
let recovered = await runner.run(config, force: true)
|
||||||
|
XCTAssertFalse(recovered.changedAnything)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPartiallyFailingWalkDoesNotLookLikeAMassDeletion() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
// Only the subfolder listing breaks: a half-built manifest must not make
|
||||||
|
// sub/b.txt look deleted.
|
||||||
|
FakeDav.nodes["/Docs/sub"]?.etag = "changed"
|
||||||
|
FakeDav.nodes["/Docs"]?.etag = "changed"
|
||||||
|
FakeDav.failPropfindPath = "/Docs/sub"
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertEqual(summary.deleted, 0)
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPathDeletedOnTheServerIsFlaggedAndItsMirrorRemoved() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.remove("/Docs")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 2)
|
||||||
|
XCTAssertEqual(store.loadMissingRoots(accountId: "acct"), ["/Docs"])
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Single files, scoping, housekeeping
|
||||||
|
|
||||||
|
func testASyncedSingleFilePath() async {
|
||||||
|
FakeDav.put("/Notes/todo.txt", "buy milk")
|
||||||
|
var single = config
|
||||||
|
single.folders = ["/Notes/todo.txt"]
|
||||||
|
|
||||||
|
let summary = await runner.run(single, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1)
|
||||||
|
XCTAssertEqual(local("Notes/todo.txt"), "buy milk")
|
||||||
|
FakeDav.put("/Notes/todo.txt", "buy milk and eggs")
|
||||||
|
_ = await runner.run(single, force: false)
|
||||||
|
XCTAssertEqual(local("Notes/todo.txt"), "buy milk and eggs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncOnlyTouchesTheConfiguredPaths() async {
|
||||||
|
seedServer()
|
||||||
|
FakeDav.put("/Other/x.txt", "not synced")
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(exists("Other/x.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNothingConfiguredDoesNothing() async {
|
||||||
|
seedServer()
|
||||||
|
var empty = config
|
||||||
|
empty.folders = []
|
||||||
|
let summary = await runner.run(empty, force: true)
|
||||||
|
XCTAssertEqual(summary, SyncRunSummary())
|
||||||
|
XCTAssertTrue(FakeDav.log.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoveLocalSyncForgetsThePathSoItComesBackOnReAdd() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
store.removeLocalSync(accountId: "acct", path: "/Docs/sub")
|
||||||
|
|
||||||
|
XCTAssertFalse(exists("Docs/sub/b.txt"))
|
||||||
|
XCTAssertTrue(exists("Docs/a.txt"))
|
||||||
|
XCTAssertEqual(state().count, 1)
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "an ancestor's marker would skip the re-download")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1)
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStatusMarksFilesAsSyncingDuringTheRun() async {
|
||||||
|
seedServer()
|
||||||
|
var seen = Set<String>()
|
||||||
|
bus.onChange = { seen.formUnion($0.syncingFileIds) }
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(seen.count, 2, "each transferring file was announced")
|
||||||
|
XCTAssertTrue(bus.snapshot().syncingFileIds.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStaleMarkersForRemovedPathsAreDropped() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
var other = config
|
||||||
|
other.folders = ["/Other"]
|
||||||
|
FakeDav.mkdir("/Other")
|
||||||
|
_ = await runner.run(other, force: false)
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parsing
|
||||||
|
|
||||||
|
func testParserReadsEtagFileIdSizeAndDates() {
|
||||||
|
let xml = Data(
|
||||||
|
"""
|
||||||
|
<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||||
|
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/</d:href><d:propstat><d:prop>
|
||||||
|
<d:resourcetype><d:collection/></d:resourcetype><d:getetag>"abc123"</d:getetag>
|
||||||
|
<oc:fileid>7</oc:fileid></d:prop></d:propstat></d:response>
|
||||||
|
<d:response><d:href>/nextcloud/remote.php/dav/files/alice/Docs/Tax%20Forms.pdf</d:href><d:propstat><d:prop>
|
||||||
|
<d:getlastmodified>Tue, 14 Nov 2023 22:13:20 GMT</d:getlastmodified>
|
||||||
|
<d:getcontentlength>2048</d:getcontentlength><d:resourcetype/>
|
||||||
|
<d:getetag>"e1"</d:getetag><oc:fileid>9</oc:fileid></d:prop></d:propstat></d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
""".utf8)
|
||||||
|
let entries = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: true)
|
||||||
|
XCTAssertEqual(entries.count, 1, "the folder itself is skipped")
|
||||||
|
XCTAssertEqual(entries[0].path, "/Docs/Tax Forms.pdf")
|
||||||
|
XCTAssertEqual(entries[0].etag, "e1", "quotes are stripped")
|
||||||
|
XCTAssertEqual(entries[0].fileId, "9")
|
||||||
|
XCTAssertEqual(entries[0].size, 2048)
|
||||||
|
XCTAssertEqual(entries[0].lastModified, 1_700_000_000_000)
|
||||||
|
XCTAssertFalse(entries[0].isFolder)
|
||||||
|
|
||||||
|
let all = try! DavSyncParser.entries(from: xml, username: "alice", requestedPath: "/Docs", skipSelf: false)
|
||||||
|
XCTAssertEqual(all.map(\.path), ["/Docs", "/Docs/Tax Forms.pdf"])
|
||||||
|
XCTAssertTrue(all[0].isFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParserRefusesAnythingThatIsNotACompleteAnswer() {
|
||||||
|
func entries(_ xml: String) throws -> [SyncRemoteEntry] {
|
||||||
|
try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/Docs", skipSelf: false)
|
||||||
|
}
|
||||||
|
XCTAssertThrowsError(try entries("nope"), "not XML")
|
||||||
|
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php"), "truncated")
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try entries(
|
||||||
|
"<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>/remote.php/dav/files/alice/Docs/a.txt</d:href></d:response>"
|
||||||
|
+ "<d:response><d:href>/remote.php/dav/files/alice/Docs/b"),
|
||||||
|
"one complete response, then cut off: a partial manifest")
|
||||||
|
XCTAssertThrowsError(try entries("<html><body>Maintenance</body></html>"), "well-formed but not a multistatus")
|
||||||
|
XCTAssertThrowsError(try entries("<d:multistatus xmlns:d=\"DAV:\"></d:multistatus>"), "a 207 always has the resource itself")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testParserRefusesHrefsOutsideTheAccountRootOrClimbingOut() {
|
||||||
|
func parse(_ href: String) throws -> [SyncRemoteEntry] {
|
||||||
|
let xml = "<d:multistatus xmlns:d=\"DAV:\"><d:response><d:href>\(href)</d:href><d:propstat><d:prop><d:resourcetype/></d:prop></d:propstat></d:response></d:multistatus>"
|
||||||
|
return try DavSyncParser.entries(from: Data(xml.utf8), username: "alice", requestedPath: "/", skipSelf: false)
|
||||||
|
}
|
||||||
|
XCTAssertNoThrow(try parse("/remote.php/dav/files/alice/Docs/a.txt"))
|
||||||
|
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/../bob/secret.txt"), "dot-dot component")
|
||||||
|
XCTAssertThrowsError(try parse("/remote.php/dav/files/alice/Docs/%2E%2E/%2E%2E/x"), "encoded dot-dot")
|
||||||
|
XCTAssertThrowsError(try parse("/somewhere/else/a.txt"), "not under the files root")
|
||||||
|
XCTAssertThrowsError(try parse("/remote.php/dav/files/alicia/a.txt"), "another user's root that merely starts the same")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Review findings: each of these used to lose or misplace data
|
||||||
|
|
||||||
|
func testATruncatedAnswerIsNotAManifest() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
FakeDav.truncatePropfind = true
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertFalse(summary.changedAnything, "half a listing must not look like deletions")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
XCTAssertTrue(store.loadMissingRoots(accountId: "acct").isEmpty, "nor like the folder being gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeletedOnTheServerButEditedHereIsDeletedLikeAndroid() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try editLocally("Docs/a.txt", "my only copy of this edit")
|
||||||
|
FakeDav.remove("/Docs/a.txt")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
XCTAssertNil(state().first { $0.value.relPath == "Docs/a.txt" }, "no longer tracked")
|
||||||
|
|
||||||
|
let again = await runner.run(config, force: false)
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"), "not restored on a later sync")
|
||||||
|
XCTAssertFalse(again.changedAnything)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSyncingTheWholeAccountNoticesServerDeletions() async {
|
||||||
|
FakeDav.put("/a.txt", "top")
|
||||||
|
FakeDav.put("/sub/b.txt", "nested")
|
||||||
|
var everything = config
|
||||||
|
everything.folders = ["/"]
|
||||||
|
|
||||||
|
_ = await runner.run(everything, force: false)
|
||||||
|
XCTAssertEqual(local("a.txt"), "top")
|
||||||
|
FakeDav.log = []
|
||||||
|
_ = await runner.run(everything, force: false)
|
||||||
|
XCTAssertEqual(depth1Requests(), 0, "the root shortcut works for '/' too")
|
||||||
|
|
||||||
|
FakeDav.remove("/a.txt")
|
||||||
|
let summary = await runner.run(everything, force: false)
|
||||||
|
XCTAssertEqual(summary.deleted, 1)
|
||||||
|
XCTAssertFalse(exists("a.txt"))
|
||||||
|
XCTAssertTrue(exists("sub/b.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAPathNeverClaimsASiblingWithTheSamePrefix() {
|
||||||
|
let entry = { (rel: String) in SyncFileState(relPath: rel, etag: "e", lastModified: 0, size: 1, localMTime: 1) }
|
||||||
|
let state = ["1": entry("Docs/a.txt"), "2": entry("Docs2/b.txt"), "3": entry("Documents/c.txt"), "4": entry("Docs")]
|
||||||
|
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs"), ["1", "4"])
|
||||||
|
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/Docs/"), ["1", "4"])
|
||||||
|
XCTAssertEqual(SyncDiff.priorFileIds(state: state, path: "/"), ["1", "2", "3", "4"], "the root covers everything")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTwoRemoteNamesForOneLocalFileAreNeitherDownloaded() async {
|
||||||
|
FakeDav.put("/Docs/a.txt", "lower")
|
||||||
|
FakeDav.put("/Docs/A.txt", "upper")
|
||||||
|
FakeDav.put("/Docs/fine.txt", "ok")
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: false)
|
||||||
|
XCTAssertEqual(summary.downloaded, 1, "only the file with no collision")
|
||||||
|
XCTAssertEqual(local("Docs/fine.txt"), "ok")
|
||||||
|
XCTAssertNil(local("Docs/a.txt"), "neither of the colliding pair overwrote the other")
|
||||||
|
XCTAssertNil(store.loadRootMarkers(accountId: "acct")["/Docs"], "and the path isn't called clean")
|
||||||
|
let unicode = SyncDiff.collidingRelPaths([
|
||||||
|
SyncRemoteEntry(path: "/x/\u{C4}.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false),
|
||||||
|
SyncRemoteEntry(path: "/x/A\u{308}.txt", fileId: "2", etag: "", lastModified: 0, size: 0, isFolder: false),
|
||||||
|
])
|
||||||
|
XCTAssertTrue(unicode.contains("x/\u{C4}.txt") && unicode.contains("x/A\u{308}.txt"),
|
||||||
|
"canonically equivalent Unicode spellings collide too")
|
||||||
|
XCTAssertTrue(
|
||||||
|
SyncDiff.collidingRelPaths([
|
||||||
|
SyncRemoteEntry(path: "/x/same.txt", fileId: "1", etag: "", lastModified: 0, size: 0, isFolder: false)
|
||||||
|
]).isEmpty, "one spelling is never a collision")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testServerPathsCanNeverLeaveTheMirror() {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent("noo-root-\(UUID().uuidString)")
|
||||||
|
XCTAssertNotNil(LocalFS.resolve("Docs/a.txt", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("../outside.txt", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("Docs/../../outside.txt", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("./x", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("", in: root))
|
||||||
|
XCTAssertNil(LocalFS.resolve("/", in: root))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnUnreadableStateMakesTheRunDoNothing() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
try editLocally("Docs/a.txt", "unsynced local edit")
|
||||||
|
let stateFile = store.base.appendingPathComponent("sync-state/acct/state.json")
|
||||||
|
try Data("{ this is not json".utf8).write(to: stateFile)
|
||||||
|
FakeDav.log = []
|
||||||
|
|
||||||
|
let summary = await runner.run(config, force: true)
|
||||||
|
XCTAssertEqual(summary, SyncRunSummary())
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "unsynced local edit", "an untracked-looking file must not be overwritten")
|
||||||
|
XCTAssertTrue(FakeDav.log.isEmpty, "it didn't even talk to the server")
|
||||||
|
XCTAssertThrowsError(try store.loadState(accountId: "acct"))
|
||||||
|
XCTAssertEqual(try Data(contentsOf: stateFile), Data("{ this is not json".utf8), "the corrupt file isn't overwritten")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoStateYetIsJustAFirstSyncNotAnError() throws {
|
||||||
|
XCTAssertEqual(try store.loadState(accountId: "brand-new"), [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testADownloadNeverReplacesAFolderOrDestroysTheOldCopyOnFailure() async throws {
|
||||||
|
FakeDav.put("/Docs/a.txt", "server")
|
||||||
|
let client = DavSyncClient(session: fakeSession)
|
||||||
|
let folder = mirror.appendingPathComponent("Docs/a.txt")
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
try "inside".write(to: folder.appendingPathComponent("keep.txt"), atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
let ok = try await client.download(creds, remotePath: "/Docs/a.txt", to: folder)
|
||||||
|
XCTAssertFalse(ok, "a folder is not replaced by a file")
|
||||||
|
XCTAssertEqual(local("Docs/a.txt/keep.txt"), "inside")
|
||||||
|
|
||||||
|
let file = mirror.appendingPathComponent("Docs/b.txt")
|
||||||
|
try "old".write(to: file, atomically: true, encoding: .utf8)
|
||||||
|
FakeDav.put("/Docs/b.txt", "new")
|
||||||
|
let replaced = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
|
||||||
|
XCTAssertTrue(replaced)
|
||||||
|
XCTAssertEqual(local("Docs/b.txt"), "new")
|
||||||
|
FakeDav.failAll = true
|
||||||
|
let failed = try await client.download(creds, remotePath: "/Docs/b.txt", to: file)
|
||||||
|
XCTAssertFalse(failed)
|
||||||
|
XCTAssertEqual(local("Docs/b.txt"), "new", "a failed download leaves what was there")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemovingTheWholeRootForgetsEveryFile() async {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
store.removeLocalSync(accountId: "acct", path: "/")
|
||||||
|
XCTAssertTrue(state().isEmpty, "'/' covers everything recorded")
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPruningNeverRemovesAFolderThatHoldsSomething() throws {
|
||||||
|
let root = mirror
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent("Docs/stale"), withIntermediateDirectories: true)
|
||||||
|
try "mine".write(to: root.appendingPathComponent("Docs/stale/note.txt"), atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: root.appendingPathComponent("Docs/empty-stale"), withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
SyncDiff.pruneRemovedFolders(syncRoot: root, rootRel: "Docs", remoteFolders: ["Docs"])
|
||||||
|
XCTAssertEqual(local("Docs/stale/note.txt"), "mine")
|
||||||
|
XCTAssertFalse(exists("Docs/empty-stale"), "an empty folder the server no longer has goes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Status, coordinator, config store, mutex
|
||||||
|
|
||||||
|
func testConflictsAreKeptPerAccount() {
|
||||||
|
let a = SyncConflict(accountId: "A", fileId: "7", remotePath: "/x", relPath: "x", name: "x")
|
||||||
|
let b = SyncConflict(accountId: "B", fileId: "7", remotePath: "/y", relPath: "y", name: "y")
|
||||||
|
bus.addConflicts(accountId: "A", [a])
|
||||||
|
bus.addConflicts(accountId: "B", [b])
|
||||||
|
XCTAssertEqual(bus.snapshot().conflicts.count, 2, "same file id, different accounts: both kept")
|
||||||
|
|
||||||
|
let coordinator = SyncCoordinator(
|
||||||
|
store: store, bus: bus, configs: SyncConfigStore(service: "test-\(UUID().uuidString)"), client: DavSyncClient())
|
||||||
|
let forB = coordinator.statusMap(bus.snapshot(), accountIdOverride: "B")["conflicts"] as? [[String: String]]
|
||||||
|
XCTAssertEqual(forB?.map { $0["relPath"] }, ["y"], "B never sees A's conflicts")
|
||||||
|
|
||||||
|
bus.removeConflict(accountId: "A", fileId: "7")
|
||||||
|
XCTAssertEqual(bus.snapshot().conflicts, [b], "removing A's leaves B's")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBackgroundSlotOnlyRunsAccountsWithPeriodicSyncEnabled() async {
|
||||||
|
seedServer()
|
||||||
|
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
|
||||||
|
addTeardownBlock { configs.removeAll() }
|
||||||
|
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient(session: fakeSession))
|
||||||
|
var scheduled = config
|
||||||
|
scheduled.intervalMinutes = nil
|
||||||
|
scheduled.wifiOnly = false
|
||||||
|
configs.upsert(scheduled)
|
||||||
|
|
||||||
|
let skipped = expectation(description: "manual-only account skipped")
|
||||||
|
coordinator.startBackgroundSlot { success in
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
skipped.fulfill()
|
||||||
|
}
|
||||||
|
await fulfillment(of: [skipped], timeout: 10)
|
||||||
|
XCTAssertFalse(exists("Docs/a.txt"))
|
||||||
|
|
||||||
|
scheduled.intervalMinutes = 30
|
||||||
|
configs.upsert(scheduled)
|
||||||
|
let completed = expectation(description: "periodic account synced")
|
||||||
|
coordinator.startBackgroundSlot { success in
|
||||||
|
XCTAssertTrue(success)
|
||||||
|
completed.fulfill()
|
||||||
|
}
|
||||||
|
await fulfillment(of: [completed], timeout: 15)
|
||||||
|
XCTAssertEqual(local("Docs/a.txt"), "alpha")
|
||||||
|
XCTAssertEqual(local("Docs/sub/b.txt"), "bravo")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSignOutForgetsCredentialsButTurningBackgroundOffKeepsThem() {
|
||||||
|
let service = "test-\(UUID().uuidString)"
|
||||||
|
let configs = SyncConfigStore(service: service)
|
||||||
|
addTeardownBlock { configs.removeAll() }
|
||||||
|
let coordinator = SyncCoordinator(store: store, bus: bus, configs: configs, client: DavSyncClient())
|
||||||
|
var withBackground = config
|
||||||
|
withBackground.intervalMinutes = 30
|
||||||
|
|
||||||
|
coordinator.reschedule(withBackground)
|
||||||
|
XCTAssertEqual(configs.config(for: "acct")?.intervalMinutes, 30)
|
||||||
|
|
||||||
|
coordinator.cancel(accountId: "acct", forget: false)
|
||||||
|
XCTAssertNotNil(configs.config(for: "acct"), "conflict actions and Sync now still need the credentials")
|
||||||
|
XCTAssertNil(configs.config(for: "acct")?.intervalMinutes, "but nothing is scheduled")
|
||||||
|
|
||||||
|
coordinator.cancel(accountId: "acct", forget: true)
|
||||||
|
XCTAssertNil(configs.config(for: "acct"), "signing out forgets them")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfigStoreHoldsSeveralAccountsAndUpdatesInPlace() {
|
||||||
|
let configs = SyncConfigStore(service: "test-\(UUID().uuidString)")
|
||||||
|
addTeardownBlock { configs.removeAll() }
|
||||||
|
let second = SyncAccountConfig(
|
||||||
|
accountId: "other", serverUrl: "https://o", username: "bob", authHeader: "Basic y",
|
||||||
|
folders: ["/x"], wifiOnly: true, intervalMinutes: 60, notify: true)
|
||||||
|
|
||||||
|
XCTAssertTrue(configs.upsert(config))
|
||||||
|
XCTAssertTrue(configs.upsert(second))
|
||||||
|
XCTAssertEqual(configs.all().map(\.accountId), ["acct", "other"])
|
||||||
|
|
||||||
|
var changed = config
|
||||||
|
changed.folders = ["/Docs", "/More"]
|
||||||
|
XCTAssertTrue(configs.upsert(changed))
|
||||||
|
XCTAssertEqual(configs.config(for: "acct")?.folders, ["/Docs", "/More"])
|
||||||
|
XCTAssertEqual(configs.config(for: "other"), second, "updating one leaves the others")
|
||||||
|
|
||||||
|
XCTAssertTrue(configs.remove(accountId: "acct"))
|
||||||
|
XCTAssertEqual(configs.all().map(\.accountId), ["other"])
|
||||||
|
XCTAssertTrue(configs.remove(accountId: "never-existed"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAQueuedWaiterThatIsCancelledLeavesTheQueueAtOnce() async throws {
|
||||||
|
let mutex = AsyncMutex()
|
||||||
|
let holderStarted = expectation(description: "holder has the lock")
|
||||||
|
let holder = Task {
|
||||||
|
try await mutex.withLock {
|
||||||
|
holderStarted.fulfill()
|
||||||
|
try await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fulfillment(of: [holderStarted], timeout: 5)
|
||||||
|
|
||||||
|
let waiter = Task { try await mutex.withLock { "ran" } }
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
let cancelledAt = Date()
|
||||||
|
waiter.cancel()
|
||||||
|
do {
|
||||||
|
_ = try await waiter.value
|
||||||
|
XCTFail("a cancelled waiter must not run")
|
||||||
|
} catch {
|
||||||
|
XCTAssertTrue(error is CancellationError)
|
||||||
|
XCTAssertLessThan(Date().timeIntervalSince(cancelledAt), 0.8, "it didn't wait for the 1.5s holder")
|
||||||
|
}
|
||||||
|
|
||||||
|
try await holder.value
|
||||||
|
let next = try await mutex.withLock { "next" }
|
||||||
|
XCTAssertEqual(next, "next", "the lock is free for the next one")
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Counter: @unchecked Sendable {
|
||||||
|
var running = 0
|
||||||
|
var maxRunning = 0
|
||||||
|
var finished = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAMutexNeverRunsTwoBodiesAtOnce() async throws {
|
||||||
|
let mutex = AsyncMutex()
|
||||||
|
let counter = Counter()
|
||||||
|
await withTaskGroup(of: Void.self) { group in
|
||||||
|
for _ in 0..<6 {
|
||||||
|
group.addTask {
|
||||||
|
_ = try? await mutex.withLock {
|
||||||
|
counter.running += 1
|
||||||
|
counter.maxRunning = max(counter.maxRunning, counter.running)
|
||||||
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
counter.running -= 1
|
||||||
|
counter.finished += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
XCTAssertEqual(counter.finished, 6)
|
||||||
|
XCTAssertEqual(counter.maxRunning, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnceGateCompletesOnlyOnce() {
|
||||||
|
var calls: [Bool] = []
|
||||||
|
let gate = OnceGate { calls.append($0) }
|
||||||
|
gate.finish(false)
|
||||||
|
gate.finish(true)
|
||||||
|
XCTAssertEqual(calls, [false])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
|
/// Face ID / Touch ID with the device passcode as the fallback - the same
|
||||||
|
/// policy the app's lock uses (`local_auth` with `biometricOnly: false`), so
|
||||||
|
/// the share sheet asks for exactly what the app would.
|
||||||
|
enum DeviceAuth {
|
||||||
|
/// True only if the user actually authenticated; a cancel, a failure, or a
|
||||||
|
/// device with nothing to authenticate with all count as "not unlocked".
|
||||||
|
static func authenticate(reason: String) async -> Bool {
|
||||||
|
let context = LAContext()
|
||||||
|
var error: NSError?
|
||||||
|
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { return false }
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
|
||||||
|
continuation.resume(returning: success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Noo</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
<string>Noo uses Face ID to unlock hidden folders and accounts when you share a file.</string>
|
||||||
|
<key>NooKeychainAccessGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
|
||||||
|
<integer>50</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.dev.ayushya.noo</string>
|
||||||
|
</array>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)dev.ayushya.noo.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// State behind the share sheet's account and folder pickers.
|
||||||
|
@MainActor
|
||||||
|
final class ShareModel: ObservableObject {
|
||||||
|
enum Stage: Equatable {
|
||||||
|
case preparing
|
||||||
|
case chooseAccount
|
||||||
|
case picking
|
||||||
|
case uploading
|
||||||
|
case noAccount
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var stage: Stage = .preparing
|
||||||
|
@Published var items: [SharedItem] = []
|
||||||
|
@Published var path = "/"
|
||||||
|
@Published var isLoadingFolders = false
|
||||||
|
@Published var folderError: String?
|
||||||
|
/// A short explanation shown under the list - e.g. hidden folders stayed
|
||||||
|
/// hidden because the unlock was cancelled.
|
||||||
|
@Published var notice: String?
|
||||||
|
@Published private(set) var selected: SharedAccount?
|
||||||
|
|
||||||
|
/// The sheet's two toggles. They start at the app's own setting for the
|
||||||
|
/// chosen account and can be changed for this share only.
|
||||||
|
@Published private(set) var hiddenFilter: HiddenFilter = .hide
|
||||||
|
@Published private(set) var storageFilter: StorageFilter = .cloud
|
||||||
|
|
||||||
|
/// Every child folder of [path], unfiltered; the toggles filter it live.
|
||||||
|
@Published private var allFolders: [DavFolder] = []
|
||||||
|
/// Mount points of external storage seen so far. Only the mount's root is
|
||||||
|
/// flagged by the server, so anything under one of these is external too.
|
||||||
|
private var externalRoots: Set<String> = []
|
||||||
|
|
||||||
|
let shared: SharedAccounts?
|
||||||
|
|
||||||
|
/// Set by the view controller: what each button actually does.
|
||||||
|
var onUpload: () -> Void = {}
|
||||||
|
var onSaveForLater: () -> Void = {}
|
||||||
|
var onCancel: () -> Void = {}
|
||||||
|
|
||||||
|
init(shared: SharedAccounts?) {
|
||||||
|
self.shared = shared
|
||||||
|
}
|
||||||
|
|
||||||
|
var accounts: [SharedAccount] { shared?.accounts ?? [] }
|
||||||
|
var canChangeAccount: Bool { accounts.count > 1 }
|
||||||
|
var activeAccountId: String? { shared?.active?.id }
|
||||||
|
|
||||||
|
var currentFolderName: String {
|
||||||
|
path == "/" ? "All files" : (path as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileCountText: String {
|
||||||
|
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Folders, filtered by the toggles
|
||||||
|
|
||||||
|
private func isExternal(_ folder: DavFolder) -> Bool {
|
||||||
|
folder.isExternal
|
||||||
|
|| externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleFolders: [DavFolder] {
|
||||||
|
allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal folders - and, for "All", the external ones are listed apart.
|
||||||
|
var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } }
|
||||||
|
var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } }
|
||||||
|
|
||||||
|
// MARK: - Accounts
|
||||||
|
|
||||||
|
/// With several accounts the list comes first; with one it goes straight
|
||||||
|
/// to its folders.
|
||||||
|
func start() async {
|
||||||
|
guard let shared, let first = shared.accounts.first else {
|
||||||
|
stage = .noAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none")
|
||||||
|
if shared.accounts.count > 1 {
|
||||||
|
stage = .chooseAccount
|
||||||
|
} else {
|
||||||
|
await select(first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showAccounts() {
|
||||||
|
notice = nil
|
||||||
|
stage = .chooseAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading to an account other than the one the app is on is "switching"
|
||||||
|
/// in the app's terms, so it asks for the same unlock when the app does.
|
||||||
|
/// The toggles start at that account's own app settings; hidden folders
|
||||||
|
/// are behind the app's "lock hidden files" unlock.
|
||||||
|
func select(_ account: SharedAccount) async {
|
||||||
|
guard let shared else { return }
|
||||||
|
notice = nil
|
||||||
|
var wantedHidden = HiddenFilter(raw: account.hiddenFilter)
|
||||||
|
|
||||||
|
// Every selection asks again, like the app does for every switch - no
|
||||||
|
// unlock is remembered. If one action needs both unlocks, one prompt
|
||||||
|
// covers both.
|
||||||
|
let needs = shared.unlockNeeds(choosing: account, showing: wantedHidden)
|
||||||
|
if needs.needsPrompt {
|
||||||
|
let reason = needs.switchesAccount
|
||||||
|
? "Unlock to upload to \(account.displayName)" : "Unlock to show hidden folders"
|
||||||
|
if await !DeviceAuth.authenticate(reason: reason) {
|
||||||
|
guard !needs.switchesAccount else {
|
||||||
|
notice = "Unlock to upload to a different account."
|
||||||
|
stage = .chooseAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wantedHidden = .hide
|
||||||
|
notice = "Hidden folders are locked, so they're not shown."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, wantedHidden.rawValue, account.storageScope)
|
||||||
|
selected = account
|
||||||
|
externalRoots = []
|
||||||
|
storageFilter = StorageFilter(raw: account.storageScope)
|
||||||
|
hiddenFilter = wantedHidden
|
||||||
|
stage = .picking
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toggles
|
||||||
|
|
||||||
|
/// Turning hidden folders *on* is what the app locks; going back to
|
||||||
|
/// hiding them never exposes anything, and neither does switching
|
||||||
|
/// between the two revealing modes.
|
||||||
|
func setHiddenFilter(_ filter: HiddenFilter) async {
|
||||||
|
guard filter != hiddenFilter else { return }
|
||||||
|
if shared?.needsUnlockToChangeHidden(from: hiddenFilter, to: filter) == true {
|
||||||
|
guard await DeviceAuth.authenticate(reason: "Unlock to show hidden folders") else {
|
||||||
|
notice = "Hidden folders are locked, so they're not shown."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notice = nil
|
||||||
|
hiddenFilter = filter
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStorageFilter(_ filter: StorageFilter) async {
|
||||||
|
guard filter != storageFilter else { return }
|
||||||
|
notice = nil
|
||||||
|
storageFilter = filter
|
||||||
|
await open("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Browsing
|
||||||
|
|
||||||
|
/// A toggle can leave you inside a folder it now hides, so changing one
|
||||||
|
/// always returns to the top - same as [open] with "/".
|
||||||
|
func open(_ path: String) async {
|
||||||
|
guard let account = selected else { return }
|
||||||
|
self.path = path
|
||||||
|
isLoadingFolders = true
|
||||||
|
folderError = nil
|
||||||
|
defer { isLoadingFolders = false }
|
||||||
|
do {
|
||||||
|
allFolders = try await DavClient.listFolders(account: account, path: path)
|
||||||
|
externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path))
|
||||||
|
} catch {
|
||||||
|
allFolders = []
|
||||||
|
folderError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openParent() async {
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
await open(parent.isEmpty ? "/" : parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SharePickerView: View {
|
||||||
|
@ObservedObject var model: ShareModel
|
||||||
|
@State private var externalExpanded = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
content
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
Button("Cancel", action: model.onCancel)
|
||||||
|
Spacer()
|
||||||
|
Text("Upload to Noo").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
// Balances Cancel so the title stays centred.
|
||||||
|
Button("Cancel", action: {}).hidden()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var content: some View {
|
||||||
|
switch model.stage {
|
||||||
|
case .preparing:
|
||||||
|
centered { ProgressView("Preparing \(model.items.isEmpty ? "" : model.fileCountText)…") }
|
||||||
|
case .uploading:
|
||||||
|
centered {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Uploading \(model.fileCountText) in the background").font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .noAccount:
|
||||||
|
centered {
|
||||||
|
Text("Sign in to Noo first, then share again. You can also save these files and choose a folder from inside Noo.")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
case .failed(let message):
|
||||||
|
centered {
|
||||||
|
Text(message).multilineTextAlignment(.center).foregroundColor(.secondary).padding()
|
||||||
|
}
|
||||||
|
case .chooseAccount:
|
||||||
|
accountList
|
||||||
|
case .picking:
|
||||||
|
folderList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accountList: some View {
|
||||||
|
List {
|
||||||
|
Section(
|
||||||
|
header: Text("Upload \(model.fileCountText) to which account?"),
|
||||||
|
footer: model.notice.map { Text($0) }
|
||||||
|
) {
|
||||||
|
ForEach(model.accounts) { account in
|
||||||
|
Button {
|
||||||
|
Task { await model.select(account) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(account.displayName, systemImage: "person.crop.circle")
|
||||||
|
Spacer()
|
||||||
|
if account.id == model.activeAccountId {
|
||||||
|
Text("Active").font(.caption).foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Folders
|
||||||
|
|
||||||
|
private var folderList: some View {
|
||||||
|
List {
|
||||||
|
Section(header: accountHeader, footer: model.notice.map { Text($0) }) {
|
||||||
|
filterRow
|
||||||
|
if model.path != "/" {
|
||||||
|
Button {
|
||||||
|
Task { await model.openParent() }
|
||||||
|
} label: {
|
||||||
|
Label("Up to \(parentName)", systemImage: "arrow.turn.left.up")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model.isLoadingFolders {
|
||||||
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
} else if let error = model.folderError {
|
||||||
|
Text(error).foregroundColor(.secondary)
|
||||||
|
} else if model.internalFolders.isEmpty && model.externalFolders.isEmpty {
|
||||||
|
Text("No folders here").foregroundColor(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(model.internalFolders, id: \.path) { folderRow($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "All" keeps external storage in its own group, like the app's list.
|
||||||
|
if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty {
|
||||||
|
Section {
|
||||||
|
DisclosureGroup(isExpanded: $externalExpanded) {
|
||||||
|
ForEach(model.externalFolders, id: \.path) { folderRow($0) }
|
||||||
|
} label: {
|
||||||
|
Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func folderRow(_ folder: DavFolder) -> some View {
|
||||||
|
Button {
|
||||||
|
Task { await model.open(folder.path) }
|
||||||
|
} label: {
|
||||||
|
Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two toggles, as menus - Hidden folders and External storage each
|
||||||
|
/// start at the app's setting and can be changed for this share.
|
||||||
|
private var filterRow: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Menu {
|
||||||
|
Picker("Hidden folders", selection: hiddenBinding) {
|
||||||
|
Text("Hide hidden folders").tag(HiddenFilter.hide)
|
||||||
|
Text("Only hidden folders").tag(HiddenFilter.only)
|
||||||
|
Text("Show all").tag(HiddenFilter.include)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
chip("eye", "Hidden", hiddenTitle)
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
Picker("External storage", selection: storageBinding) {
|
||||||
|
Text("Cloud only").tag(StorageFilter.cloud)
|
||||||
|
Text("Only external storage").tag(StorageFilter.external)
|
||||||
|
Text("All + external storage").tag(StorageFilter.all)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
chip("externaldrive", "Storage", storageTitle)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chip(_ icon: String, _ name: String, _ value: String) -> some View {
|
||||||
|
Label("\(name): \(value)", systemImage: icon)
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(Color(.secondarySystemFill)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hiddenBinding: Binding<HiddenFilter> {
|
||||||
|
Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageBinding: Binding<StorageFilter> {
|
||||||
|
Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hiddenTitle: String {
|
||||||
|
switch model.hiddenFilter {
|
||||||
|
case .hide: return "Hide"
|
||||||
|
case .only: return "Only"
|
||||||
|
case .include: return "All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storageTitle: String {
|
||||||
|
switch model.storageFilter {
|
||||||
|
case .cloud: return "Cloud"
|
||||||
|
case .external: return "External"
|
||||||
|
case .all: return "All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accountHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Text(model.selected?.displayName ?? "")
|
||||||
|
Spacer()
|
||||||
|
if model.canChangeAccount {
|
||||||
|
Button("Change account") { model.showAccounts() }
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var parentName: String {
|
||||||
|
let parent = (model.path as NSString).deletingLastPathComponent
|
||||||
|
return parent.isEmpty || parent == "/" ? "All files" : (parent as NSString).lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footer: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if model.stage == .picking {
|
||||||
|
Button(action: model.onUpload) {
|
||||||
|
Text("Upload \(model.fileCountText) to \(model.currentFolderName)")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(model.isLoadingFolders)
|
||||||
|
}
|
||||||
|
if model.stage == .picking || model.stage == .chooseAccount || model.stage == .noAccount {
|
||||||
|
Button("Choose a folder later in Noo", action: model.onSaveForLater)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func centered<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {
|
||||||
|
VStack { Spacer(); content(); Spacer() }.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// "Share to Noo". Like Reminders' or Notes' share sheet, the destination is
|
||||||
|
/// chosen *inside* the sheet - an extension can't open its host app - so this
|
||||||
|
/// shows a folder picker, then starts a background upload and closes:
|
||||||
|
///
|
||||||
|
/// 1. Copies whatever was shared into the App Group (`SharedInbox`).
|
||||||
|
/// 2. Reads the accounts the app left in the shared Keychain
|
||||||
|
/// (`SharedAccountStore`) - asking which one first if there are several -
|
||||||
|
/// and lists folders over WebDAV (`DavClient`), honouring the app's
|
||||||
|
/// hidden-files filter and its unlock settings (`ShareModel`).
|
||||||
|
/// 3. "Upload" queues the files on a background session that outlives this
|
||||||
|
/// process (`ShareUpload`); the app is relaunched to finish and notify.
|
||||||
|
///
|
||||||
|
/// With no account, or if the user prefers, the files are instead left in the
|
||||||
|
/// inbox for the app to offer its own destination picker next time it opens
|
||||||
|
/// (with a notification saying so).
|
||||||
|
final class ShareViewController: UIViewController {
|
||||||
|
private var model: ShareModel!
|
||||||
|
private var batchDirectory: URL?
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
model = ShareModel(shared: SharedAccountStore.load())
|
||||||
|
model.onUpload = { [weak self] in self?.upload() }
|
||||||
|
model.onSaveForLater = { [weak self] in self?.saveForLater() }
|
||||||
|
model.onCancel = { [weak self] in self?.cancel() }
|
||||||
|
|
||||||
|
let host = UIHostingController(rootView: SharePickerView(model: model))
|
||||||
|
addChild(host)
|
||||||
|
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(host.view)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
])
|
||||||
|
host.didMove(toParent: self)
|
||||||
|
|
||||||
|
Task { await prepare() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Importing
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func prepare() async {
|
||||||
|
guard let root = AppGroup.containerURL else {
|
||||||
|
model.stage = .failed("Noo isn't set up to receive files yet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let providers =
|
||||||
|
(extensionContext?.inputItems as? [NSExtensionItem])?.flatMap { $0.attachments ?? [] } ?? []
|
||||||
|
|
||||||
|
do {
|
||||||
|
let batch = try SharedInbox.makeBatchDirectory(in: root)
|
||||||
|
batchDirectory = batch
|
||||||
|
for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.item.identifier) {
|
||||||
|
if let item = try? await copy(provider, into: batch) { model.items.append(item) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !model.items.isEmpty else {
|
||||||
|
model.stage = .failed("Noo can only receive files and photos.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await model.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `loadFileRepresentation`'s URL only lives for the duration of its
|
||||||
|
/// callback, so the copy happens inside it.
|
||||||
|
private func copy(_ provider: NSItemProvider, into directory: URL) async throws -> SharedItem {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
|
||||||
|
guard let url else {
|
||||||
|
return continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown))
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let destination = LocalFiles.uniqueURL(in: directory, name: url.lastPathComponent)
|
||||||
|
try FileManager.default.copyItem(at: url, to: destination)
|
||||||
|
let size = (try? FileManager.default.attributesOfItem(atPath: destination.path))?[.size] as? Int64 ?? 0
|
||||||
|
let mime = UTType(filenameExtension: destination.pathExtension)?.preferredMIMEType
|
||||||
|
continuation.resume(
|
||||||
|
returning: SharedItem(
|
||||||
|
path: destination.path, name: destination.lastPathComponent, mimeType: mime, size: size))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func upload() {
|
||||||
|
guard let account = model.selected else { return }
|
||||||
|
do {
|
||||||
|
try ShareUpload.enqueue(items: model.items, account: account, remoteFolder: model.path)
|
||||||
|
model.stage = .uploading
|
||||||
|
complete(after: 1.2)
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't start the upload: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveForLater() {
|
||||||
|
guard let root = AppGroup.containerURL else { return cancel() }
|
||||||
|
do {
|
||||||
|
try SharedInbox.append(model.items, in: root)
|
||||||
|
notifyOpenNoo(count: model.items.count)
|
||||||
|
complete(after: 0)
|
||||||
|
} catch {
|
||||||
|
model.stage = .failed("Couldn't save to Noo: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancel() {
|
||||||
|
if let batchDirectory { try? FileManager.default.removeItem(at: batchDirectory) }
|
||||||
|
extensionContext?.cancelRequest(withError: CocoaError(.userCancelled))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func complete(after delay: TimeInterval) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
|
self.extensionContext?.completeRequest(returningItems: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyOpenNoo(count: Int) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body =
|
||||||
|
count == 1
|
||||||
|
? "Open Noo to choose where to upload 1 file."
|
||||||
|
: "Open Noo to choose where to upload \(count) files."
|
||||||
|
content.sound = .default
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A folder in the account's WebDAV tree. [path] is relative to the user's
|
||||||
|
/// root with a leading slash ("/Documents/Tax Forms"); "/" is the root.
|
||||||
|
/// [isExternal] is true for an external-storage mount point (Nextcloud's
|
||||||
|
/// `nc:mount-type` = `external`) - only the mount's root carries it, its
|
||||||
|
/// children don't, so callers track mount roots to know what's inside one.
|
||||||
|
struct DavFolder: Equatable {
|
||||||
|
let name: String
|
||||||
|
let path: String
|
||||||
|
var isExternal = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's Files storage scope (`StorageScope` in Dart): internal ("cloud")
|
||||||
|
/// folders only (the default), only external-storage ones, or both.
|
||||||
|
enum StorageFilter: String {
|
||||||
|
case cloud, external, all
|
||||||
|
|
||||||
|
init(raw: String) {
|
||||||
|
self = StorageFilter(rawValue: raw) ?? .cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
func shows(isExternal: Bool) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .cloud: return !isExternal
|
||||||
|
case .external: return isExternal
|
||||||
|
case .all: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app's Files "hidden files" filter (`HiddenFilesFilter` in Dart):
|
||||||
|
/// leave dot-folders out (`hide`, the default), list only them (`only`), or
|
||||||
|
/// list everything (`include`). A folder counts as hidden when it - or any
|
||||||
|
/// folder above it - starts with a dot, exactly as in the app.
|
||||||
|
enum HiddenFilter: String {
|
||||||
|
case hide, only, include
|
||||||
|
|
||||||
|
init(raw: String) {
|
||||||
|
self = HiddenFilter(rawValue: raw) ?? .hide
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isHidden(path: String) -> Bool {
|
||||||
|
path.split(separator: "/").contains { $0.hasPrefix(".") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func shows(path: String) -> Bool {
|
||||||
|
switch self {
|
||||||
|
case .hide: return !Self.isHidden(path: path)
|
||||||
|
case .only: return Self.isHidden(path: path)
|
||||||
|
case .include: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a WebDAV `PROPFIND` multistatus into folders. Pure (no
|
||||||
|
/// networking), so it's unit-tested. Compiled into both targets.
|
||||||
|
enum DavFolderParser {
|
||||||
|
private struct Response {
|
||||||
|
var href = ""
|
||||||
|
var isCollection = false
|
||||||
|
var mountType = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Delegate: NSObject, XMLParserDelegate {
|
||||||
|
var responses: [Response] = []
|
||||||
|
private var current: Response?
|
||||||
|
private var text = ""
|
||||||
|
|
||||||
|
private func local(_ name: String) -> String {
|
||||||
|
name.split(separator: ":").last.map(String.init) ?? name
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]
|
||||||
|
) {
|
||||||
|
switch local(elementName) {
|
||||||
|
case "response": current = Response()
|
||||||
|
case "href", "mount-type": text = ""
|
||||||
|
case "collection": current?.isCollection = true
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||||
|
text += string
|
||||||
|
}
|
||||||
|
|
||||||
|
func parser(
|
||||||
|
_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?,
|
||||||
|
qualifiedName qName: String?
|
||||||
|
) {
|
||||||
|
switch local(elementName) {
|
||||||
|
case "href": current?.href = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
case "mount-type": current?.mountType = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
case "response":
|
||||||
|
if let current { responses.append(current) }
|
||||||
|
current = nil
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "/nextcloud/remote.php/dav/files/alice/Tax%20Forms/" -> "/Tax Forms".
|
||||||
|
/// The server may live under a sub-path, so the files root is located by
|
||||||
|
/// its marker instead of assumed to start at the beginning.
|
||||||
|
static func relativePath(fromHref href: String) -> String? {
|
||||||
|
let decoded = href.removingPercentEncoding ?? href
|
||||||
|
let marker = "/remote.php/dav/files/"
|
||||||
|
guard let range = decoded.range(of: marker) else { return nil }
|
||||||
|
var parts = decoded[range.upperBound...].split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
guard !parts.isEmpty else { return nil }
|
||||||
|
parts.removeFirst() // the username
|
||||||
|
return "/" + parts.joined(separator: "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Child folders of [currentPath], sorted by name, filtered by [hidden].
|
||||||
|
/// The requested folder itself (which a Depth-1 PROPFIND also returns) is
|
||||||
|
/// always left out.
|
||||||
|
static func folders(
|
||||||
|
from xml: Data, excluding currentPath: String, hidden: HiddenFilter = .hide
|
||||||
|
) -> [DavFolder] {
|
||||||
|
let delegate = Delegate()
|
||||||
|
let parser = XMLParser(data: xml)
|
||||||
|
parser.delegate = delegate
|
||||||
|
parser.parse()
|
||||||
|
|
||||||
|
let current = currentPath == "/" ? "/" : currentPath.replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
|
||||||
|
return delegate.responses
|
||||||
|
.filter(\.isCollection)
|
||||||
|
.compactMap { response -> DavFolder? in
|
||||||
|
guard let path = relativePath(fromHref: response.href), path != current else { return nil }
|
||||||
|
let name = (path as NSString).lastPathComponent
|
||||||
|
guard !name.isEmpty, hidden.shows(path: path) else { return nil }
|
||||||
|
return DavFolder(name: name, path: path, isExternal: response.mountType == "external")
|
||||||
|
}
|
||||||
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists folders over WebDAV for the Share Extension's picker, in the
|
||||||
|
/// foreground (the sheet is on screen while it runs).
|
||||||
|
enum DavClient {
|
||||||
|
private static let body = """
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns"><d:prop><d:resourcetype/><nc:mount-type/></d:prop></d:propfind>
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// Every child folder, hidden and external ones included - the sheet's
|
||||||
|
/// toggles filter that list in memory, so changing one doesn't refetch.
|
||||||
|
/// Two accounts can live on the same server, so a request must be
|
||||||
|
/// authenticated by its own `Authorization` header alone - never by a
|
||||||
|
/// session cookie or credential a previous account's request left behind
|
||||||
|
/// in the shared stores, or the second account could be answered as the
|
||||||
|
/// first.
|
||||||
|
private static let session: URLSession = {
|
||||||
|
let config = URLSessionConfiguration.ephemeral
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func listFolders(account: SharedAccount, path: String) async throws -> [DavFolder] {
|
||||||
|
guard let url = WebDAV.fileURL(serverUrl: account.serverUrl, username: account.username, remotePath: path)
|
||||||
|
else { throw TransferError(message: "Invalid server address.") }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PROPFIND"
|
||||||
|
request.setValue("1", forHTTPHeaderField: "Depth")
|
||||||
|
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = body.data(using: .utf8)
|
||||||
|
request.timeoutInterval = 20
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
NSLog("[DavClient] PROPFIND %@ as %@ -> %d", url.path, account.username, status)
|
||||||
|
guard status == 207 else {
|
||||||
|
throw TransferError(message: status == 401 ? "Signed out - open Noo to sign in again." : "Server returned \(status).")
|
||||||
|
}
|
||||||
|
return DavFolderParser.folders(from: data, excluding: path, hidden: .include)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Starts the Share Extension's uploads on a background `URLSession` that
|
||||||
|
/// outlives the extension. The extension process is torn down as soon as its
|
||||||
|
/// sheet closes; because the session has a `sharedContainerIdentifier` and
|
||||||
|
/// the same identifier is recreated by the app at launch
|
||||||
|
/// (`TransferManager.reconnect`), the *app* is what gets relaunched to
|
||||||
|
/// receive the results, post the summary notification and clean up.
|
||||||
|
/// Compiled into both targets.
|
||||||
|
enum ShareUpload {
|
||||||
|
static let sessionIdentifier = "dev.ayushya.noo.transfers.share"
|
||||||
|
|
||||||
|
static func configuration() -> URLSessionConfiguration {
|
||||||
|
let config = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
|
||||||
|
config.sharedContainerIdentifier = AppGroup.identifier
|
||||||
|
config.sessionSendsLaunchEvents = true
|
||||||
|
config.isDiscretionary = false
|
||||||
|
config.waitsForConnectivity = true
|
||||||
|
// Authenticate by the request's own header only (see DavClient.session).
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
config.httpCookieAcceptPolicy = .never
|
||||||
|
config.urlCredentialStorage = nil
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `PUT` for [item] into [remoteFolder] - shared by the extension's
|
||||||
|
/// [enqueue] and the app's own uploads so both build requests identically.
|
||||||
|
static func uploadRequest(for item: SharedItem, account: SharedAccount, remoteFolder: String) -> URLRequest? {
|
||||||
|
guard
|
||||||
|
let url = WebDAV.fileURL(
|
||||||
|
serverUrl: account.serverUrl,
|
||||||
|
username: account.username,
|
||||||
|
remotePath: WebDAV.join(remoteFolder, item.name)
|
||||||
|
)
|
||||||
|
else { return nil }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "PUT"
|
||||||
|
request.setValue(account.authHeader, forHTTPHeaderField: "Authorization")
|
||||||
|
if let modified = (try? FileManager.default.attributesOfItem(atPath: item.path))?[.modificationDate] as? Date {
|
||||||
|
request.setValue(String(Int(modified.timeIntervalSince1970)), forHTTPHeaderField: "X-OC-Mtime")
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues every item (already copied into the App Group by the extension)
|
||||||
|
/// and returns once the system has accepted them.
|
||||||
|
static func enqueue(items: [SharedItem], account: SharedAccount, remoteFolder: String) throws {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
let batch = UUID().uuidString
|
||||||
|
// Small files can finish while the extension is still on screen; for
|
||||||
|
// those this delegate counts them. Whatever outlives the extension is
|
||||||
|
// reported to the app instead (see `TransferManager`).
|
||||||
|
let session = URLSession(configuration: configuration(), delegate: ShareUploadDelegate(), delegateQueue: nil)
|
||||||
|
|
||||||
|
var tasks: [URLSessionUploadTask] = []
|
||||||
|
for item in items {
|
||||||
|
guard let request = uploadRequest(for: item, account: account, remoteFolder: remoteFolder) else {
|
||||||
|
throw TransferError(message: "Invalid server address.")
|
||||||
|
}
|
||||||
|
let task = session.uploadTask(with: request, fromFile: URL(fileURLWithPath: item.path))
|
||||||
|
task.taskDescription =
|
||||||
|
(try? JSONEncoder().encode(
|
||||||
|
TransferTaskInfo(
|
||||||
|
kind: .upload, batch: batch, name: item.name, remoteFolder: remoteFolder, stagedPath: item.path)
|
||||||
|
)).flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
tasks.append(task)
|
||||||
|
}
|
||||||
|
TransferBatchStore.save(
|
||||||
|
TransferBatch(kind: .upload, remoteFolder: remoteFolder, total: tasks.count, succeeded: 0, failed: 0),
|
||||||
|
id: batch)
|
||||||
|
TransferNotifications.started(kind: .upload, batch: batch, count: tasks.count)
|
||||||
|
tasks.forEach { $0.resume() }
|
||||||
|
session.finishTasksAndInvalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TransferError: LocalizedError {
|
||||||
|
let message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// One signed-in account, as much of it as the Share Extension needs to list
|
||||||
|
/// folders and upload. Compiled into both targets.
|
||||||
|
struct SharedAccount: Codable, Equatable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let serverUrl: String
|
||||||
|
let username: String
|
||||||
|
/// `Basic ...` - the app password never leaves the Keychain as plain text.
|
||||||
|
let authHeader: String
|
||||||
|
/// Shown in the picker, e.g. "alice@cloud.example.com".
|
||||||
|
let displayName: String
|
||||||
|
/// The app's Files "hidden files" filter for this account - `hide` (the
|
||||||
|
/// default), `only` or `include` (see `HiddenFilter`). The share sheet
|
||||||
|
/// follows it instead of having a setting of its own.
|
||||||
|
let hiddenFilter: String
|
||||||
|
/// The app's storage scope for this account - `cloud` (the default),
|
||||||
|
/// `external` or `all` (see `StorageFilter`).
|
||||||
|
let storageScope: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String, serverUrl: String, username: String, authHeader: String, displayName: String,
|
||||||
|
hiddenFilter: String = "hide", storageScope: String = "cloud"
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.serverUrl = serverUrl
|
||||||
|
self.username = username
|
||||||
|
self.authHeader = authHeader
|
||||||
|
self.displayName = displayName
|
||||||
|
self.hiddenFilter = hiddenFilter
|
||||||
|
self.storageScope = storageScope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tolerates data written before a field existed instead of failing the
|
||||||
|
/// whole decode (which would look like "signed out" to the extension).
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.init(
|
||||||
|
id: try c.decode(String.self, forKey: .id),
|
||||||
|
serverUrl: try c.decode(String.self, forKey: .serverUrl),
|
||||||
|
username: try c.decode(String.self, forKey: .username),
|
||||||
|
authHeader: try c.decode(String.self, forKey: .authHeader),
|
||||||
|
displayName: try c.decode(String.self, forKey: .displayName),
|
||||||
|
hiddenFilter: try c.decodeIfPresent(String.self, forKey: .hiddenFilter) ?? "hide",
|
||||||
|
storageScope: try c.decodeIfPresent(String.self, forKey: .storageScope) ?? "cloud"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the app publishes for the extension: every account that can
|
||||||
|
/// upload, which one is active in the app, and the app-lock settings the
|
||||||
|
/// extension has to honour. The app rewrites it whenever any of that changes.
|
||||||
|
struct SharedAccounts: Codable, Equatable {
|
||||||
|
var accounts: [SharedAccount]
|
||||||
|
var activeId: String?
|
||||||
|
/// Settings -> Security: the three independent locks. `loginLockEnabled`
|
||||||
|
/// (unlock to open the app) is carried for completeness; the extension's own
|
||||||
|
/// gates are the other two.
|
||||||
|
var loginLockEnabled: Bool
|
||||||
|
var lockAccountSwitching: Bool
|
||||||
|
var lockHiddenFiles: Bool
|
||||||
|
|
||||||
|
/// The account the app is currently using, else the first one.
|
||||||
|
var active: SharedAccount? {
|
||||||
|
accounts.first { $0.id == activeId } ?? accounts.first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploading to an account other than the active one is "switching" in
|
||||||
|
/// the app's terms, so it needs the same unlock.
|
||||||
|
var needsUnlockToSwitchAccount: Bool { lockAccountSwitching }
|
||||||
|
|
||||||
|
/// Showing hidden folders needs the same unlock the app asks for when you
|
||||||
|
/// turn hidden files on.
|
||||||
|
var needsUnlockForHidden: Bool { lockHiddenFiles }
|
||||||
|
|
||||||
|
/// What choosing [account] (with [hidden] folders to show) asks the user
|
||||||
|
/// to unlock. One action, one prompt - when both the account switch and
|
||||||
|
/// the hidden folders need it, a single authentication covers both.
|
||||||
|
func unlockNeeds(choosing account: SharedAccount, showing hidden: HiddenFilter) -> SelectionUnlock {
|
||||||
|
SelectionUnlock(
|
||||||
|
switchesAccount: account.id != active?.id && needsUnlockToSwitchAccount,
|
||||||
|
showsHidden: hidden != .hide && needsUnlockForHidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning hidden folders on (from `hide`) is what the app locks; going
|
||||||
|
/// back to `hide`, or between the two revealing modes, never asks.
|
||||||
|
func needsUnlockToChangeHidden(from old: HiddenFilter, to new: HiddenFilter) -> Bool {
|
||||||
|
old == .hide && new != .hide && needsUnlockForHidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
|
||||||
|
/// extension are entitled to (`keychain-access-groups`). The group's full id
|
||||||
|
/// carries the signing team's prefix, so it's injected into each target's
|
||||||
|
/// Info.plist as `NooKeychainAccessGroup` (= `$(AppIdentifierPrefix)` +
|
||||||
|
/// `dev.ayushya.noo.shared`) instead of being hard-coded - both processes
|
||||||
|
/// then always agree on it, with or without a team.
|
||||||
|
enum SharedAccountStore {
|
||||||
|
private static let service = "dev.ayushya.noo.shared-accounts"
|
||||||
|
private static let account = "all"
|
||||||
|
|
||||||
|
static var accessGroup: String? {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "NooKeychainAccessGroup") as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func baseQuery() -> [String: Any] {
|
||||||
|
var query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
if let group = accessGroup, !group.isEmpty {
|
||||||
|
query[kSecAttrAccessGroup as String] = group
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ value: SharedAccounts) throws {
|
||||||
|
let data = try JSONEncoder().encode(value)
|
||||||
|
clear()
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecValueData as String] = data
|
||||||
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
let status = SecItemAdd(query as CFDictionary, nil)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load() -> SharedAccounts? {
|
||||||
|
var query = baseQuery()
|
||||||
|
query[kSecReturnData as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: AnyObject?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
||||||
|
let data = result as? Data,
|
||||||
|
let decoded = try? JSONDecoder().decode(SharedAccounts.self, from: data),
|
||||||
|
!decoded.accounts.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func clear() {
|
||||||
|
SecItemDelete(baseQuery() as CFDictionary)
|
||||||
|
// The first version kept a single account under its own service name;
|
||||||
|
// don't leave that credential behind.
|
||||||
|
var legacy = baseQuery()
|
||||||
|
legacy[kSecAttrService as String] = "dev.ayushya.noo.shared-account"
|
||||||
|
legacy[kSecAttrAccount as String] = "active"
|
||||||
|
SecItemDelete(legacy as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one selection needs unlocked - see `SharedAccounts.unlockNeeds`.
|
||||||
|
struct SelectionUnlock: Equatable {
|
||||||
|
let switchesAccount: Bool
|
||||||
|
let showsHidden: Bool
|
||||||
|
|
||||||
|
var needsPrompt: Bool { switchesAccount || showsHidden }
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The App Group both the app and its Share Extension belong to - the one
|
||||||
|
/// place they can exchange files.
|
||||||
|
enum AppGroup {
|
||||||
|
static let identifier = "group.dev.ayushya.noo"
|
||||||
|
|
||||||
|
static var containerURL: URL? {
|
||||||
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file the Share Extension copied into the group container.
|
||||||
|
struct SharedItem: Codable, Equatable {
|
||||||
|
let path: String
|
||||||
|
let name: String
|
||||||
|
let mimeType: String?
|
||||||
|
let size: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files the Share Extension received, waiting for the app to ask the user
|
||||||
|
/// where to upload them. The extension appends; the app consumes (reads and
|
||||||
|
/// clears) when it launches or becomes active. Compiled into both targets.
|
||||||
|
///
|
||||||
|
/// Layout under `<root>/SharedInbox/`: `pending.json` (the manifest) and one
|
||||||
|
/// `<batch-id>/` folder per share holding the copied files.
|
||||||
|
enum SharedInbox {
|
||||||
|
static let folderName = "SharedInbox"
|
||||||
|
private static let manifestName = "pending.json"
|
||||||
|
|
||||||
|
static func directory(in root: URL) -> URL {
|
||||||
|
root.appendingPathComponent(folderName, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh folder for one share's files.
|
||||||
|
static func makeBatchDirectory(in root: URL) throws -> URL {
|
||||||
|
let dir = directory(in: root).appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
static func append(_ items: [SharedItem], in root: URL) throws {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
try withManifestLock(in: root) {
|
||||||
|
let existing = (try? readManifest(in: root)) ?? []
|
||||||
|
try writeManifest(existing + items, in: root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything pending, dropping entries whose file has gone missing, and
|
||||||
|
/// clears the manifest so each share is delivered once.
|
||||||
|
static func consume(in root: URL) -> [SharedItem] {
|
||||||
|
var items: [SharedItem] = []
|
||||||
|
try? withManifestLock(in: root) {
|
||||||
|
items = ((try? readManifest(in: root)) ?? []).filter {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
try? FileManager.default.removeItem(at: manifestURL(in: root))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func manifestURL(in root: URL) -> URL {
|
||||||
|
directory(in: root).appendingPathComponent(manifestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readManifest(in root: URL) throws -> [SharedItem] {
|
||||||
|
let data = try Data(contentsOf: manifestURL(in: root))
|
||||||
|
return try JSONDecoder().decode([SharedItem].self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func writeManifest(_ items: [SharedItem], in root: URL) throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory(in: root), withIntermediateDirectories: true)
|
||||||
|
try JSONEncoder().encode(items).write(to: manifestURL(in: root), options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extension and the app are separate processes that can both touch the
|
||||||
|
/// manifest, so read-modify-write goes through a file coordinator.
|
||||||
|
private static func withManifestLock(in root: URL, _ body: () throws -> Void) throws {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory(in: root), withIntermediateDirectories: true)
|
||||||
|
var coordinationError: NSError?
|
||||||
|
var thrown: Error?
|
||||||
|
NSFileCoordinator().coordinate(
|
||||||
|
writingItemAt: directory(in: root), options: [], error: &coordinationError
|
||||||
|
) { _ in
|
||||||
|
do { try body() } catch { thrown = error }
|
||||||
|
}
|
||||||
|
if let error = coordinationError ?? thrown.map({ $0 as NSError }) { throw error }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// The local notifications for an upload/download batch - one "Uploading 3
|
||||||
|
/// files…" when it starts, replaced in place by "Uploaded 3 files" when it
|
||||||
|
/// ends (both use the batch's id as the request identifier, so the second
|
||||||
|
/// *replaces* the first instead of stacking). Posted from whichever process
|
||||||
|
/// sees the event - the app, or the Share Extension while it's still alive.
|
||||||
|
/// iOS can't update a notification with live byte progress from a background
|
||||||
|
/// session, so start/finish is as much as a notification can say.
|
||||||
|
/// Compiled into both targets.
|
||||||
|
enum TransferNotifications {
|
||||||
|
static func started(kind: TransferTaskInfo.Kind, batch: String, count: Int) {
|
||||||
|
let verb = kind == .upload ? "Uploading" : "Downloading"
|
||||||
|
post(id: batch, body: "\(verb) \(files(count))…", sound: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func finished(_ batch: TransferBatch, id: String) {
|
||||||
|
let verb = batch.kind == .upload ? "upload" : "download"
|
||||||
|
let past = batch.kind == .upload ? "Uploaded" : "Downloaded"
|
||||||
|
let body: String
|
||||||
|
if batch.failed == 0 {
|
||||||
|
body = "\(past) \(files(batch.succeeded))"
|
||||||
|
} else if batch.succeeded == 0 {
|
||||||
|
body = "Couldn't \(verb) \(files(batch.failed))"
|
||||||
|
} else {
|
||||||
|
body = "\(past) \(files(batch.succeeded)), \(batch.failed) failed"
|
||||||
|
}
|
||||||
|
post(id: id, body: body, sound: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func files(_ count: Int) -> String {
|
||||||
|
count == 1 ? "1 file" : "\(count) files"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(id: String, body: String, sound: Bool) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Noo"
|
||||||
|
content.body = body
|
||||||
|
if sound { content.sound = .default }
|
||||||
|
UNUserNotificationCenter.current().add(
|
||||||
|
UNNotificationRequest(identifier: "transfer.\(id)", content: content, trigger: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counting one finished upload task - shared so the result is handled the
|
||||||
|
/// same way whether the Share Extension (still alive) or the app (relaunched
|
||||||
|
/// later) receives it.
|
||||||
|
enum UploadResults {
|
||||||
|
/// Deletes the task's staged copy, records its outcome, and - if it was the
|
||||||
|
/// last file of its batch - posts the summary notification and returns the
|
||||||
|
/// finished batch.
|
||||||
|
static func handle(_ task: URLSessionTask, error: Error?) -> (info: TransferTaskInfo, batch: TransferBatch?)? {
|
||||||
|
guard let info = task.taskDescription
|
||||||
|
.flatMap({ $0.data(using: .utf8) })
|
||||||
|
.flatMap({ try? JSONDecoder().decode(TransferTaskInfo.self, from: $0) }),
|
||||||
|
info.kind == .upload
|
||||||
|
else { return nil }
|
||||||
|
if let staged = info.stagedPath { try? FileManager.default.removeItem(atPath: staged) }
|
||||||
|
let status = (task.response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let ok = error == nil && (200..<300).contains(status)
|
||||||
|
let batch = TransferBatchStore.record(info, success: ok)
|
||||||
|
if let batch { TransferNotifications.finished(batch, id: info.batch) }
|
||||||
|
return (info, batch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate for the Share Extension's upload session while the extension is
|
||||||
|
/// still alive: files that finish before it closes are counted here, the
|
||||||
|
/// rest by the app (`TransferManager`) once the system relaunches it.
|
||||||
|
final class ShareUploadDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
|
guard let result = UploadResults.handle(task, error: error) else { return }
|
||||||
|
// The app wasn't running to hear about this, so leave a note for it to
|
||||||
|
// refresh that folder next time it's active.
|
||||||
|
if let batch = result.batch, batch.succeeded > 0, let folder = batch.remoteFolder {
|
||||||
|
TransferBatchStore.noteFinishedUpload(folder: folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// What a background upload/download task is, stored in the task's
|
||||||
|
/// `taskDescription` so it's still known if the app was relaunched by the
|
||||||
|
/// system to deliver the result - possibly for a task the Share Extension
|
||||||
|
/// started. Compiled into both targets.
|
||||||
|
struct TransferTaskInfo: Codable {
|
||||||
|
enum Kind: String, Codable { case upload, download }
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
let batch: String
|
||||||
|
let name: String
|
||||||
|
let remoteFolder: String?
|
||||||
|
/// A file to delete once the task ends (the upload's staged copy).
|
||||||
|
let stagedPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running totals for one "upload these 3 files" / "download these 2 files"
|
||||||
|
/// request, so the one summary notification (and the Dart-side "folder
|
||||||
|
/// changed" event) fires exactly once, on the last file.
|
||||||
|
struct TransferBatch: Codable {
|
||||||
|
let kind: TransferTaskInfo.Kind
|
||||||
|
let remoteFolder: String?
|
||||||
|
let total: Int
|
||||||
|
var succeeded: Int
|
||||||
|
var failed: Int
|
||||||
|
|
||||||
|
var finished: Int { succeeded + failed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists [TransferBatch]es in the App Group's `UserDefaults`, not the
|
||||||
|
/// process's own: the Share Extension starts an upload batch, and the app -
|
||||||
|
/// a different process, possibly relaunched in the background - finishes
|
||||||
|
/// counting it.
|
||||||
|
enum TransferBatchStore {
|
||||||
|
private static let lock = NSLock()
|
||||||
|
|
||||||
|
private static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: AppGroup.identifier) ?? .standard
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func key(_ id: String) -> String { "transfer.batch.\(id)" }
|
||||||
|
|
||||||
|
static func save(_ batch: TransferBatch, id: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: key(id)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let finishedFoldersKey = "transfer.finishedUploadFolders"
|
||||||
|
|
||||||
|
/// The Share Extension finished an upload into [folder] while the app
|
||||||
|
/// wasn't running; the app reads this on its next activation so its file
|
||||||
|
/// list can refresh.
|
||||||
|
static func noteFinishedUpload(folder: String) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
var folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
if !folders.contains(folder) { folders.append(folder) }
|
||||||
|
defaults.set(folders, forKey: finishedFoldersKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func consumeFinishedUploadFolders() -> [String] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let folders = defaults.stringArray(forKey: finishedFoldersKey) ?? []
|
||||||
|
defaults.removeObject(forKey: finishedFoldersKey)
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Counts one file's outcome. Returns the batch when that was its last
|
||||||
|
/// file (and forgets it), nil while others are still running.
|
||||||
|
static func record(_ info: TransferTaskInfo, success: Bool) -> TransferBatch? {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
let storageKey = key(info.batch)
|
||||||
|
var batch =
|
||||||
|
defaults.data(forKey: storageKey).flatMap { try? JSONDecoder().decode(TransferBatch.self, from: $0) }
|
||||||
|
?? TransferBatch(kind: info.kind, remoteFolder: info.remoteFolder, total: 1, succeeded: 0, failed: 0)
|
||||||
|
if success { batch.succeeded += 1 } else { batch.failed += 1 }
|
||||||
|
if batch.finished >= batch.total {
|
||||||
|
defaults.removeObject(forKey: storageKey)
|
||||||
|
return batch
|
||||||
|
}
|
||||||
|
if let data = try? JSONEncoder().encode(batch) { defaults.set(data, forKey: storageKey) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// URL building for a Nextcloud account's WebDAV files root. Pure functions
|
||||||
|
/// (no networking), so they're unit-tested in `RunnerTests`.
|
||||||
|
enum WebDAV {
|
||||||
|
/// `urlPathAllowed` minus "/", so a file name containing a slash-like
|
||||||
|
/// character can't introduce an extra path segment.
|
||||||
|
private static let segmentAllowed: CharacterSet = {
|
||||||
|
var set = CharacterSet.urlPathAllowed
|
||||||
|
set.remove(charactersIn: "/")
|
||||||
|
return set
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func encodeSegment(_ segment: String) -> String {
|
||||||
|
segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<server>/remote.php/dav/files/<user>/<remotePath>` - [remotePath] is
|
||||||
|
/// relative to the user's root, with or without a leading slash. A server
|
||||||
|
/// installed under a sub-path (`https://host/nextcloud`) keeps it.
|
||||||
|
static func fileURL(serverUrl: String, username: String, remotePath: String) -> URL? {
|
||||||
|
var base = serverUrl
|
||||||
|
while base.hasSuffix("/") { base.removeLast() }
|
||||||
|
let segments = remotePath.split(separator: "/").map { encodeSegment(String($0)) }
|
||||||
|
var url = base + "/remote.php/dav/files/" + encodeSegment(username)
|
||||||
|
if !segments.isEmpty { url += "/" + segments.joined(separator: "/") }
|
||||||
|
return URL(string: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `folder` + `name` with exactly one slash between them.
|
||||||
|
static func join(_ folder: String, _ name: String) -> String {
|
||||||
|
folder.hasSuffix("/") ? folder + name : folder + "/" + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocalFiles {
|
||||||
|
/// A URL in [directory] for [name] that doesn't exist yet: "a.txt", then
|
||||||
|
/// "a (1).txt", "a (2).txt"... - so a second download of the same file
|
||||||
|
/// never overwrites the first.
|
||||||
|
static func uniqueURL(in directory: URL, name: String, fileManager: FileManager = .default) -> URL {
|
||||||
|
let candidate = directory.appendingPathComponent(name)
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) { return candidate }
|
||||||
|
let ext = (name as NSString).pathExtension
|
||||||
|
let stem = (name as NSString).deletingPathExtension
|
||||||
|
var index = 1
|
||||||
|
while true {
|
||||||
|
let numbered = ext.isEmpty ? "\(stem) (\(index))" : "\(stem) (\(index)).\(ext)"
|
||||||
|
let url = directory.appendingPathComponent(numbered)
|
||||||
|
if !fileManager.fileExists(atPath: url.path) { return url }
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import 'providers/shares_controller.dart';
|
|||||||
import 'providers/sync_status_controller.dart';
|
import 'providers/sync_status_controller.dart';
|
||||||
import 'providers/trash_controller.dart';
|
import 'providers/trash_controller.dart';
|
||||||
import 'services/pick_intent_service.dart';
|
import 'services/pick_intent_service.dart';
|
||||||
|
import 'services/share_account_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 'theme/design_tokens.dart';
|
||||||
@@ -31,12 +32,13 @@ import 'views/share_upload_view.dart';
|
|||||||
import 'widgets/app_drawer.dart';
|
import 'widgets/app_drawer.dart';
|
||||||
import 'widgets/app_tab_view_builder.dart';
|
import 'widgets/app_tab_view_builder.dart';
|
||||||
import 'widgets/app_top_bar.dart';
|
import 'widgets/app_top_bar.dart';
|
||||||
|
import 'widgets/avatar_menu.dart';
|
||||||
import 'widgets/bottom_nav_bar.dart';
|
import 'widgets/bottom_nav_bar.dart';
|
||||||
import 'widgets/create_menu.dart';
|
import 'widgets/create_menu.dart';
|
||||||
import 'widgets/noo/core/noo_avatar.dart';
|
import 'widgets/noo/core/noo_avatar.dart';
|
||||||
import 'widgets/noo/core/noo_button.dart';
|
import 'widgets/noo/core/noo_button.dart';
|
||||||
import 'widgets/noo/core/noo_fab.dart';
|
import 'widgets/noo/core/noo_fab.dart';
|
||||||
import 'widgets/noo/nav/noo_nav_style.dart';
|
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||||
import 'widgets/noo/noo_layout.dart';
|
import 'widgets/noo/noo_layout.dart';
|
||||||
@@ -58,7 +60,14 @@ void main() {
|
|||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => SessionController(context.read()),
|
create: (context) {
|
||||||
|
final session = SessionController(context.read());
|
||||||
|
// iOS's Share Extension has no Flutter engine; sign-out must wipe
|
||||||
|
// what it was given. (Publishing happens in the shell - see
|
||||||
|
// ShareAccountSync - once there's a FilesController to read.)
|
||||||
|
session.addAccountClearedListener(ShareAccountService.clear);
|
||||||
|
return session;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => SettingsController()),
|
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||||
// Before SyncStatusController, which follows its Files Cache rule
|
// Before SyncStatusController, which follows its Files Cache rule
|
||||||
@@ -202,9 +211,11 @@ class MainShellView extends StatefulWidget {
|
|||||||
|
|
||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView> {
|
||||||
late AppTab _currentTab;
|
late AppTab _currentTab;
|
||||||
|
final Set<AppTab> _selectionActiveTabs = {};
|
||||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
StreamSubscription<List<SharedFileRef>>? _shareSub;
|
||||||
StreamSubscription<PickRequest>? _pickSub;
|
StreamSubscription<PickRequest>? _pickSub;
|
||||||
|
ShareAccountSync? _shareAccountSync;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -226,6 +237,13 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
ShareIntentService.getInitialShare().then(_handleSharedFiles);
|
||||||
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
_shareSub = ShareIntentService.onNewShare.listen(_handleSharedFiles);
|
||||||
|
|
||||||
|
// iOS's Share Extension picks accounts/folders on its own, so it's kept
|
||||||
|
// supplied with the accounts, lock settings and hidden-files filters.
|
||||||
|
_shareAccountSync = ShareAccountSync(
|
||||||
|
context.read<SessionController>(),
|
||||||
|
context.read<FilesController>(),
|
||||||
|
)..start();
|
||||||
|
|
||||||
// Same cold-start-vs-already-running split as the share intent above:
|
// Same cold-start-vs-already-running split as the share intent above:
|
||||||
// another app may have launched Noo as its GET_CONTENT picker.
|
// another app may have launched Noo as its GET_CONTENT picker.
|
||||||
PickIntentService.getPickRequest().then((request) {
|
PickIntentService.getPickRequest().then((request) {
|
||||||
@@ -310,6 +328,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
}
|
}
|
||||||
_shareSub?.cancel();
|
_shareSub?.cancel();
|
||||||
_pickSub?.cancel();
|
_pickSub?.cancel();
|
||||||
|
_shareAccountSync?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,24 +432,41 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
// null`).
|
// null`).
|
||||||
final tabStack = Stack(
|
final tabStack = Stack(
|
||||||
children: [
|
children: [
|
||||||
IndexedStack(
|
AvatarNavigationBody(
|
||||||
index: selectedIndex,
|
child: IndexedStack(
|
||||||
children: displayTabs
|
index: selectedIndex,
|
||||||
.map(
|
children: displayTabs
|
||||||
(tab) => buildAppTabView(
|
.map(
|
||||||
tab,
|
(tab) => buildAppTabView(
|
||||||
_scrollControllers[tab]!,
|
tab,
|
||||||
topBar: isDesktop || pickRequest != null
|
_scrollControllers[tab]!,
|
||||||
? null
|
onSelectionChanged: (selecting) {
|
||||||
: AppTopBar(
|
if (!mounted ||
|
||||||
style: navStyle,
|
selecting == _selectionActiveTabs.contains(tab)) {
|
||||||
tab: tab,
|
return;
|
||||||
searchInBottomBar: showBottomBarSearch,
|
}
|
||||||
navMenuStyle: settings.navMenuStyle,
|
setState(() {
|
||||||
),
|
if (selecting) {
|
||||||
),
|
_selectionActiveTabs.add(tab);
|
||||||
)
|
} else {
|
||||||
.toList(),
|
_selectionActiveTabs.remove(tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
topBar: isDesktop || pickRequest != null
|
||||||
|
? null
|
||||||
|
: AppTopBar(
|
||||||
|
style: navStyle,
|
||||||
|
tab: tab,
|
||||||
|
searchInBottomBar: showBottomBarSearch,
|
||||||
|
navMenuStyle: settings.navMenuStyle,
|
||||||
|
avatarPosition: settings.avatarPosition,
|
||||||
|
uploadButtonStyle: settings.fabStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||||
],
|
],
|
||||||
@@ -526,15 +562,21 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||||
// Floating needs the body to draw behind the bar's own transparent
|
// Floating needs the body to draw behind the bar's own transparent
|
||||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||||
// short of it like attached does.
|
// short of it like attached does; a frosted bar needs it too, or
|
||||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
// there'd be nothing behind it to blur.
|
||||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
extendBody: NooBottomBar.drawsBehindBody(
|
||||||
|
bottomBarStyle,
|
||||||
|
settings.bottomBarFrosted,
|
||||||
|
),
|
||||||
|
// Android-only extended Upload FAB - iOS uses the top bar's Upload
|
||||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||||
// rather than the Scaffold popping it fully in/out on every tab
|
// rather than the Scaffold popping it fully in/out on every tab
|
||||||
// switch - see NooFab's [collapsed].
|
// switch - see NooFab's [collapsed].
|
||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
pickRequest == null && navStyle == NooNavStyle.android
|
pickRequest == null &&
|
||||||
|
navStyle == NooNavStyle.android &&
|
||||||
|
!_selectionActiveTabs.contains(selectedTab)
|
||||||
? NooFab(
|
? NooFab(
|
||||||
collapsed: switch (settings.fabStyle) {
|
collapsed: switch (settings.fabStyle) {
|
||||||
FabStyle.auto => !canUpload,
|
FabStyle.auto => !canUpload,
|
||||||
@@ -545,13 +587,35 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
onTap: () => showCreateMenu(context),
|
onTap: () => showCreateMenu(context),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: tabStack,
|
body: TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(
|
||||||
|
end: switch (settings.fabStyle) {
|
||||||
|
FabStyle.auto => canUpload ? 1.0 : 0.0,
|
||||||
|
FabStyle.mini => 0.0,
|
||||||
|
FabStyle.expanded => 1.0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
duration: MediaQuery.disableAnimationsOf(context)
|
||||||
|
? Duration.zero
|
||||||
|
: NooMotion.base,
|
||||||
|
curve: NooMotion.ease,
|
||||||
|
child: tabStack,
|
||||||
|
builder: (context, progress, child) =>
|
||||||
|
UploadButtonAnimation(progress: progress, child: child!),
|
||||||
|
),
|
||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
style: navStyle,
|
style: navStyle,
|
||||||
barStyle: bottomBarStyle,
|
barStyle: bottomBarStyle,
|
||||||
|
frosted: settings.bottomBarFrosted,
|
||||||
|
frostedBlur: settings.bottomBarFrostedBlur,
|
||||||
|
frostedOpacity: settings.bottomBarFrostedOpacity,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
|
avatarInBottomBar:
|
||||||
|
pickRequest == null &&
|
||||||
|
settings.navMenuStyle == NooNavMenuStyle.avatarMenu &&
|
||||||
|
settings.avatarPosition == AvatarPosition.bottom,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
final tappedTab = pinnedTabs[index];
|
final tappedTab = pinnedTabs[index];
|
||||||
if (tappedTab == _currentTab) {
|
if (tappedTab == _currentTab) {
|
||||||
@@ -591,7 +655,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: isDesktop ? scaffold : AvatarNavigationHost(child: scaffold),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ import 'session_controller.dart';
|
|||||||
|
|
||||||
enum FileSortField { name, dateCreated, dateModified, size }
|
enum FileSortField { name, dateCreated, dateModified, size }
|
||||||
|
|
||||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
/// external items in their own collapsible section then). Shared across
|
||||||
enum StorageScope { cloud, external }
|
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
|
||||||
|
enum StorageScope { cloud, external, all }
|
||||||
|
|
||||||
|
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
|
||||||
|
/// default), show [only] them, or [include] them alongside everything else.
|
||||||
|
/// Files and Photos each keep their own (see [applyCommonFilters]).
|
||||||
|
enum HiddenFilesFilter { hide, only, include }
|
||||||
|
|
||||||
/// The Files tab's files/folders/both filter — independent of and applied
|
/// The Files tab's files/folders/both filter — independent of and applied
|
||||||
/// after [StorageScope]/favorites/hidden filtering.
|
/// after [StorageScope]/favorites/hidden filtering.
|
||||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
|||||||
static const _prefStorageScope = 'ui_storage_scope';
|
static const _prefStorageScope = 'ui_storage_scope';
|
||||||
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 _prefHiddenFilter = 'ui_hidden_filter';
|
||||||
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';
|
||||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
|||||||
bool _isGridView = false;
|
bool _isGridView = false;
|
||||||
StorageScope _storageScope = StorageScope.cloud;
|
StorageScope _storageScope = StorageScope.cloud;
|
||||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||||
bool _showHiddenFiles = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
|
|
||||||
final Map<String, FileSortField> _folderSortField = {};
|
final Map<String, FileSortField> _folderSortField = {};
|
||||||
final Map<String, bool> _folderSortAscending = {};
|
final Map<String, bool> _folderSortAscending = {};
|
||||||
@@ -135,7 +142,7 @@ class FilesController extends ChangeNotifier
|
|||||||
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;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||||
|
|
||||||
@@ -188,24 +195,30 @@ class FilesController extends ChangeNotifier
|
|||||||
List<NextcloudItem> applyCommonFilters(
|
List<NextcloudItem> applyCommonFilters(
|
||||||
List<NextcloudItem> source, {
|
List<NextcloudItem> source, {
|
||||||
required bool showFavoritesOnly,
|
required bool showFavoritesOnly,
|
||||||
required bool showHidden,
|
required HiddenFilesFilter hidden,
|
||||||
bool applyStorageScope = true,
|
bool applyStorageScope = true,
|
||||||
}) {
|
}) {
|
||||||
var filtered = source;
|
var filtered = source;
|
||||||
if (showFavoritesOnly) {
|
if (showFavoritesOnly) {
|
||||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||||
}
|
}
|
||||||
if (!showHidden) {
|
switch (hidden) {
|
||||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
case HiddenFilesFilter.hide:
|
||||||
|
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||||
|
case HiddenFilesFilter.only:
|
||||||
|
filtered = filtered.where(_isHiddenItem).toList();
|
||||||
|
case HiddenFilesFilter.include:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (applyStorageScope) {
|
if (applyStorageScope) {
|
||||||
filtered = filtered
|
switch (_storageScope) {
|
||||||
.where(
|
case StorageScope.cloud:
|
||||||
(item) => _storageScope == StorageScope.external
|
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||||
? item.isExternalStorage
|
case StorageScope.external:
|
||||||
: !item.isExternalStorage,
|
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||||
)
|
case StorageScope.all:
|
||||||
.toList();
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
@@ -231,7 +244,7 @@ class FilesController extends ChangeNotifier
|
|||||||
// 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,
|
hidden: _hiddenFilter,
|
||||||
applyStorageScope: applyStorageScope,
|
applyStorageScope: applyStorageScope,
|
||||||
);
|
);
|
||||||
switch (_filesTypeFilter) {
|
switch (_filesTypeFilter) {
|
||||||
@@ -270,7 +283,7 @@ class FilesController extends ChangeNotifier
|
|||||||
_isGridView = false;
|
_isGridView = false;
|
||||||
_storageScope = StorageScope.cloud;
|
_storageScope = StorageScope.cloud;
|
||||||
_filesTypeFilter = FilesTypeFilter.all;
|
_filesTypeFilter = FilesTypeFilter.all;
|
||||||
_showHiddenFiles = false;
|
_hiddenFilter = HiddenFilesFilter.hide;
|
||||||
_cachePolicy = defaultCachePolicy;
|
_cachePolicy = defaultCachePolicy;
|
||||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -321,7 +334,11 @@ class FilesController extends ChangeNotifier
|
|||||||
(f) => f.name == filesTypeFilterName,
|
(f) => f.name == filesTypeFilterName,
|
||||||
orElse: () => FilesTypeFilter.all,
|
orElse: () => FilesTypeFilter.all,
|
||||||
);
|
);
|
||||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
_hiddenFilter = loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
k(_prefHiddenFilter),
|
||||||
|
k(_prefShowHiddenFiles),
|
||||||
|
);
|
||||||
|
|
||||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||||
if (folderSortJson != null) {
|
if (folderSortJson != null) {
|
||||||
@@ -639,10 +656,12 @@ class FilesController extends ChangeNotifier
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||||
/// never exposes anything, so that direction is always allowed instantly.
|
/// never exposes anything, so that direction is always allowed instantly
|
||||||
Future<void> toggleShowHiddenFiles() async {
|
/// (and switching between the two revealing modes needs no second prompt).
|
||||||
if (!_showHiddenFiles) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -650,14 +669,61 @@ class FilesController extends ChangeNotifier
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHiddenFiles = !_showHiddenFiles;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenFiles,
|
_prefHiddenFilter,
|
||||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
(p, key) => p.setString(key, filter.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The storage scope saved for [accountId] (see [savedHiddenFilter]).
|
||||||
|
static StorageScope savedStorageScope(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String Function(String accountId, String baseKey) accountPrefKey,
|
||||||
|
String accountId,
|
||||||
|
) {
|
||||||
|
final name = prefs.getString(accountPrefKey(accountId, _prefStorageScope));
|
||||||
|
return StorageScope.values.firstWhere(
|
||||||
|
(s) => s.name == name,
|
||||||
|
orElse: () => StorageScope.cloud,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hidden-files filter saved for [accountId] - not just the active
|
||||||
|
/// account's, which is all [hiddenFilter] holds. For iOS's Share Extension,
|
||||||
|
/// which follows each account's own setting.
|
||||||
|
static HiddenFilesFilter savedHiddenFilter(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String Function(String accountId, String baseKey) accountPrefKey,
|
||||||
|
String accountId,
|
||||||
|
) => loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
accountPrefKey(accountId, _prefHiddenFilter),
|
||||||
|
accountPrefKey(accountId, _prefShowHiddenFiles),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||||
|
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||||
|
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||||
|
/// already account-namespaced by the caller.
|
||||||
|
static HiddenFilesFilter loadHiddenFilter(
|
||||||
|
SharedPreferences prefs,
|
||||||
|
String key,
|
||||||
|
String legacyBoolKey,
|
||||||
|
) {
|
||||||
|
final name = prefs.getString(key);
|
||||||
|
if (name != null) {
|
||||||
|
return HiddenFilesFilter.values.firstWhere(
|
||||||
|
(f) => f.name == name,
|
||||||
|
orElse: () => HiddenFilesFilter.hide,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (prefs.getBool(legacyBoolKey) ?? false)
|
||||||
|
? HiddenFilesFilter.include
|
||||||
|
: HiddenFilesFilter.hide;
|
||||||
|
}
|
||||||
|
|
||||||
void _persistFolderSort() {
|
void _persistFolderSort() {
|
||||||
final combined = <String, dynamic>{};
|
final combined = <String, dynamic>{};
|
||||||
for (final path in {
|
for (final path in {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
import '../models/nextcloud_item.dart';
|
import '../models/nextcloud_item.dart';
|
||||||
import '../services/sync_service.dart';
|
import '../services/sync_service.dart';
|
||||||
import 'files_controller.dart';
|
import 'files_controller.dart';
|
||||||
@@ -132,7 +131,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_syncBasePath ??= (await getExternalStorageDirectory())?.path;
|
_syncBasePath ??= (await SyncService.baseDirectory())?.path;
|
||||||
// A sync can delete the very folder being browsed (deleted on the
|
// A sync can delete the very folder being browsed (deleted on the
|
||||||
// server) - step out to the nearest ancestor that still exists rather
|
// server) - step out to the nearest ancestor that still exists rather
|
||||||
// than showing a phantom empty folder.
|
// than showing a phantom empty folder.
|
||||||
@@ -186,7 +185,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
Future<String?> localPathFor(NextcloudItem item) async {
|
Future<String?> localPathFor(NextcloudItem item) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return null;
|
if (accountId == null) return null;
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = item.path.startsWith('/')
|
final relPath = item.path.startsWith('/')
|
||||||
? item.path.substring(1)
|
? item.path.substring(1)
|
||||||
@@ -199,7 +198,7 @@ class OfflineController extends ChangeNotifier
|
|||||||
String accountId,
|
String accountId,
|
||||||
String folderPath,
|
String folderPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await SyncService.baseDirectory();
|
||||||
if (base == null) return [];
|
if (base == null) return [];
|
||||||
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
final relFolder = folderPath == '/' ? '' : folderPath.replaceFirst('/', '');
|
||||||
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
final dir = Directory(p.join(base.path, 'sync', accountId, relFolder));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
|
|
||||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||||
|
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||||
static const _prefSortField = 'ui_sort_field';
|
static const _prefSortField = 'ui_sort_field';
|
||||||
static const _prefSortAscending = 'ui_sort_ascending';
|
static const _prefSortAscending = 'ui_sort_ascending';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
bool _showFavoritesOnly = false;
|
bool _showFavoritesOnly = false;
|
||||||
bool _showHidden = false;
|
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||||
FileSortField _sortField = FileSortField.name;
|
FileSortField _sortField = FileSortField.name;
|
||||||
bool _sortAscending = true;
|
bool _sortAscending = true;
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||||
bool get showHidden => _showHidden;
|
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||||
FileSortField get sortField => _sortField;
|
FileSortField get sortField => _sortField;
|
||||||
bool get sortAscending => _sortAscending;
|
bool get sortAscending => _sortAscending;
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
|||||||
final filtered = files.applyCommonFilters(
|
final filtered = files.applyCommonFilters(
|
||||||
media,
|
media,
|
||||||
showFavoritesOnly: _showFavoritesOnly,
|
showFavoritesOnly: _showFavoritesOnly,
|
||||||
showHidden: _showHidden,
|
hidden: _hiddenFilter,
|
||||||
)..sort((a, b) => _compare(a, b));
|
)..sort((a, b) => _compare(a, b));
|
||||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||||
_showFavoritesOnly =
|
_showFavoritesOnly =
|
||||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
k(_prefHiddenFilterPhotos),
|
||||||
|
k(_prefShowHiddenPhotos),
|
||||||
|
);
|
||||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||||
_sortField = FileSortField.values.firstWhere(
|
_sortField = FileSortField.values.firstWhere(
|
||||||
(f) => f.name == sortFieldName,
|
(f) => f.name == sortFieldName,
|
||||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShowHidden() async {
|
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||||
if (!_showHidden) {
|
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||||
|
if (_hiddenFilter == filter) return;
|
||||||
|
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||||
if (!await session.passGate(
|
if (!await session.passGate(
|
||||||
session.lockHiddenFiles,
|
session.lockHiddenFiles,
|
||||||
'Unlock to show hidden files',
|
'Unlock to show hidden files',
|
||||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_showHidden = !_showHidden;
|
_hiddenFilter = filter;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_persistAccountPref(
|
_persistAccountPref(
|
||||||
_prefShowHiddenPhotos,
|
_prefHiddenFilterPhotos,
|
||||||
(p, key) => p.setBool(key, _showHidden),
|
(p, key) => p.setString(key, filter.name),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// session-restore that failed, e.g. transient network trouble at cold
|
/// session-restore that failed, e.g. transient network trouble at cold
|
||||||
/// start), this still retries rather than no-op, since that's exactly the
|
/// start), this still retries rather than no-op, since that's exactly the
|
||||||
/// case LoginView's "Continue as" tile exists to recover from. Gated
|
/// case LoginView's "Continue as" tile exists to recover from. Gated
|
||||||
/// behind login lock when [lockAccountSwitching] is on. Returns whether
|
/// behind its own unlock when [lockAccountSwitching] is on. Returns whether
|
||||||
/// the account ended up logged in, so callers (LoginView's saved-account
|
/// the account ended up logged in, so callers (LoginView's saved-account
|
||||||
/// tile) can surface a failure - e.g. a stored app password that no
|
/// tile) can surface a failure - e.g. a stored app password that no
|
||||||
/// longer works and needs the account removed/re-added.
|
/// longer works and needs the account removed/re-added.
|
||||||
@@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns login lock off, along with both of its sub-toggles (meaningless
|
/// Turns login lock off. Requires a successful auth first, same as turning
|
||||||
/// once the base lock is gone). Requires a successful auth first, same as
|
/// it on - otherwise anyone with momentary access to an unlocked phone
|
||||||
/// turning it on - otherwise anyone with momentary access to an unlocked
|
/// could just switch it off. Only this lock: the account-switching and
|
||||||
/// phone could just switch it off.
|
/// hidden-files locks are independent and stay as they are.
|
||||||
Future<bool> disableLoginLock() async {
|
Future<bool> disableLoginLock() async {
|
||||||
if (!_loginLockEnabled) return true;
|
if (!_loginLockEnabled) return true;
|
||||||
final confirmed = await AppLockService.authenticate(
|
final confirmed = await AppLockService.authenticate(
|
||||||
@@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
_loginLockEnabled = false;
|
_loginLockEnabled = false;
|
||||||
_lockAccountSwitching = false;
|
|
||||||
_lockHiddenFiles = false;
|
|
||||||
_isUnlocked = false;
|
_isUnlocked = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) {
|
prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false));
|
||||||
p.setBool(_prefLoginLockEnabled, false);
|
|
||||||
p.setBool(_prefLockAccountSwitching, false);
|
|
||||||
p.setBool(_prefLockHiddenFiles, false);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLockAccountSwitching(bool value) {
|
/// Turns the "unlock to switch accounts" gate on or off. Independent of
|
||||||
if (!_loginLockEnabled) return;
|
/// login lock. Both directions need a successful auth - enabling proves the
|
||||||
|
/// device can authenticate at all (a gate nobody can pass would lock the
|
||||||
|
/// user out of switching), disabling stops anyone with momentary access to
|
||||||
|
/// an unlocked phone from just removing it. Returns whether it changed.
|
||||||
|
Future<bool> setLockAccountSwitching(bool value) async {
|
||||||
|
if (value == _lockAccountSwitching) return true;
|
||||||
|
if (!await _confirmLockChange(
|
||||||
|
value,
|
||||||
|
'Confirm to lock account switching',
|
||||||
|
'Confirm to unlock account switching',
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_lockAccountSwitching = value;
|
_lockAccountSwitching = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLockHiddenFiles(bool value) {
|
/// Same as [setLockAccountSwitching], for revealing hidden files.
|
||||||
if (!_loginLockEnabled) return;
|
Future<bool> setLockHiddenFiles(bool value) async {
|
||||||
|
if (value == _lockHiddenFiles) return true;
|
||||||
|
if (!await _confirmLockChange(
|
||||||
|
value,
|
||||||
|
'Confirm to lock hidden files',
|
||||||
|
'Confirm to unlock hidden files',
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
_lockHiddenFiles = value;
|
_lockHiddenFiles = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _confirmLockChange(
|
||||||
|
bool enabling,
|
||||||
|
String enableReason,
|
||||||
|
String disableReason,
|
||||||
|
) async {
|
||||||
|
if (enabling && !await AppLockService.isDeviceSupported()) return false;
|
||||||
|
return AppLockService.authenticate(enabling ? enableReason : disableReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called by the lock screen. Returns whether it actually unlocked.
|
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||||
@@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prompts for auth if [gate] is on and login lock is configured;
|
/// Prompts for auth if [gate] is on; returns true immediately (no prompt)
|
||||||
/// returns true immediately (no prompt) otherwise. Shared by the
|
/// otherwise. Each gate stands on its own - it doesn't depend on login lock
|
||||||
/// account-switching gate above and [FilesController]'s/`PhotosController`'s
|
/// being on. Shared by the account-switching gate above and
|
||||||
/// hidden-files gates.
|
/// [FilesController]'s/`PhotosController`'s hidden-files gates.
|
||||||
Future<bool> passGate(bool gate, String reason) async {
|
Future<bool> passGate(bool gate, String reason) async {
|
||||||
if (!_loginLockEnabled || !gate) return true;
|
if (!gate) return true;
|
||||||
return AppLockService.authenticate(reason);
|
return AppLockService.authenticate(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
|||||||
/// in Settings.
|
/// in Settings.
|
||||||
enum SwipeAction { none, favorite, delete, share }
|
enum SwipeAction { none, favorite, delete, share }
|
||||||
|
|
||||||
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
|
/// How the mobile Upload button is sized: [auto] expands on Files/Photos and
|
||||||
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
||||||
/// always shows the label.
|
/// always shows the label.
|
||||||
enum FabStyle { auto, mini, expanded }
|
enum FabStyle { auto, mini, expanded }
|
||||||
|
|
||||||
|
/// Where bulk selection actions float within the content pane.
|
||||||
|
enum SelectionBarPosition { top, bottom }
|
||||||
|
|
||||||
|
enum AvatarPosition { top, bottom }
|
||||||
|
|
||||||
/// Thumb/track presets for the video player's seek bar, matching the four
|
/// Thumb/track presets for the video player's seek bar, matching the four
|
||||||
/// combinations offered by other Material You media players: a Material 3
|
/// combinations offered by other Material You media players: a Material 3
|
||||||
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
||||||
@@ -21,6 +26,30 @@ enum FabStyle { auto, mini, expanded }
|
|||||||
/// animated wave with a tick-mark thumb ([squiggly]).
|
/// animated wave with a tick-mark thumb ([squiggly]).
|
||||||
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
enum MediaProgressBarStyle { classic, wavy, slim, squiggly }
|
||||||
|
|
||||||
|
/// The frosted-glass bottom bar's blur sigma and surface opacity, in three
|
||||||
|
/// ready-made strengths (Settings, Appearance). Moving either slider off all
|
||||||
|
/// three leaves no preset selected (a custom mix).
|
||||||
|
enum FrostedGlassPreset {
|
||||||
|
less(blur: 10, opacity: 0.88, label: 'Less'),
|
||||||
|
standard(blur: 20, opacity: 0.72, label: 'Default'),
|
||||||
|
more(blur: 32, opacity: 0.55, label: 'More');
|
||||||
|
|
||||||
|
final double blur;
|
||||||
|
final double opacity;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const FrostedGlassPreset({
|
||||||
|
required this.blur,
|
||||||
|
required this.opacity,
|
||||||
|
required this.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const double minFrostedBlur = 0;
|
||||||
|
const double maxFrostedBlur = 40;
|
||||||
|
const double minFrostedOpacity = 0.3;
|
||||||
|
const double maxFrostedOpacity = 1.0;
|
||||||
|
|
||||||
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
|
/// Global (not per-account) UI settings: theme, bottom-nav appearance, tab
|
||||||
/// configuration, swipe actions. Independent of login state - these mean
|
/// configuration, swipe actions. Independent of login state - these mean
|
||||||
/// the same thing whether any account is signed in or not, unlike
|
/// the same thing whether any account is signed in or not, unlike
|
||||||
@@ -31,6 +60,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
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 _prefTabOrder = 'ui_tab_order';
|
static const _prefTabOrder = 'ui_tab_order';
|
||||||
|
static const _prefSelectionBarPosition = 'ui_selection_bar_position';
|
||||||
static const _prefSelectionActionOrder = 'ui_selection_action_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';
|
||||||
@@ -38,6 +68,10 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
static const _prefFabStyle = 'ui_fab_style';
|
static const _prefFabStyle = 'ui_fab_style';
|
||||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
|
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
||||||
|
static const _prefFrostedBlur = 'ui_bottom_bar_frosted_blur';
|
||||||
|
static const _prefFrostedOpacity = 'ui_bottom_bar_frosted_opacity';
|
||||||
|
static const _prefAvatarPosition = 'ui_avatar_position';
|
||||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
@@ -51,12 +85,17 @@ class SettingsController extends ChangeNotifier {
|
|||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
|
AvatarPosition _avatarPosition = AvatarPosition.top;
|
||||||
|
bool _bottomBarFrosted = false;
|
||||||
|
double _frostedBlur = FrostedGlassPreset.standard.blur;
|
||||||
|
double _frostedOpacity = FrostedGlassPreset.standard.opacity;
|
||||||
bool _searchInBottomBar = false;
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
||||||
|
|
||||||
bool _tapTabToScrollTop = true;
|
bool _tapTabToScrollTop = true;
|
||||||
|
SelectionBarPosition _selectionBarPosition = SelectionBarPosition.top;
|
||||||
|
|
||||||
List<AppTab> _tabOrder = AppTab.values.toList();
|
List<AppTab> _tabOrder = AppTab.values.toList();
|
||||||
Set<AppTab> _hiddenTabs = {};
|
Set<AppTab> _hiddenTabs = {};
|
||||||
@@ -95,12 +134,23 @@ class SettingsController extends ChangeNotifier {
|
|||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||||
|
double get bottomBarFrostedBlur => _frostedBlur;
|
||||||
|
double get bottomBarFrostedOpacity => _frostedOpacity;
|
||||||
|
|
||||||
|
/// The preset the current blur/opacity match exactly, or null when the
|
||||||
|
/// sliders have been moved to something custom.
|
||||||
|
FrostedGlassPreset? get frostedPreset => FrostedGlassPreset.values
|
||||||
|
.where((p) => p.blur == _frostedBlur && p.opacity == _frostedOpacity)
|
||||||
|
.firstOrNull;
|
||||||
|
AvatarPosition get avatarPosition => _avatarPosition;
|
||||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
bool get searchInBottomBar => _searchInBottomBar;
|
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;
|
||||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||||
|
SelectionBarPosition get selectionBarPosition => _selectionBarPosition;
|
||||||
|
|
||||||
/// The cap a screen should actually enforce for the *regular*, user-
|
/// The cap a screen should actually enforce for the *regular*, user-
|
||||||
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
||||||
@@ -141,6 +191,11 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => ThemeMode.system,
|
orElse: () => ThemeMode.system,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_selectionBarPosition = SelectionBarPosition.values.firstWhere(
|
||||||
|
(position) =>
|
||||||
|
position.name == prefs.getString(_prefSelectionBarPosition),
|
||||||
|
orElse: () => SelectionBarPosition.top,
|
||||||
|
);
|
||||||
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
||||||
if (bottomBarStyleName != null) {
|
if (bottomBarStyleName != null) {
|
||||||
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
||||||
@@ -148,6 +203,10 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _bottomBarStyle,
|
orElse: () => _bottomBarStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_avatarPosition = AvatarPosition.values.firstWhere(
|
||||||
|
(position) => position.name == prefs.getString(_prefAvatarPosition),
|
||||||
|
orElse: () => AvatarPosition.top,
|
||||||
|
);
|
||||||
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
||||||
if (navMenuStyleName != null) {
|
if (navMenuStyleName != null) {
|
||||||
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
||||||
@@ -155,6 +214,17 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => _navMenuStyle,
|
orElse: () => _navMenuStyle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_bottomBarFrosted =
|
||||||
|
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||||
|
_frostedBlur = (prefs.getDouble(_prefFrostedBlur) ?? _frostedBlur).clamp(
|
||||||
|
minFrostedBlur,
|
||||||
|
maxFrostedBlur,
|
||||||
|
);
|
||||||
|
_frostedOpacity =
|
||||||
|
(prefs.getDouble(_prefFrostedOpacity) ?? _frostedOpacity).clamp(
|
||||||
|
minFrostedOpacity,
|
||||||
|
maxFrostedOpacity,
|
||||||
|
);
|
||||||
_searchInBottomBar =
|
_searchInBottomBar =
|
||||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
@@ -306,6 +376,22 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setAvatarPosition(AvatarPosition position) {
|
||||||
|
if (_avatarPosition == position) return;
|
||||||
|
_avatarPosition = position;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefAvatarPosition, position.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSelectionBarPosition(SelectionBarPosition position) {
|
||||||
|
if (_selectionBarPosition == position) return;
|
||||||
|
_selectionBarPosition = position;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then(
|
||||||
|
(p) => p.setString(_prefSelectionBarPosition, position.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void setBottomBarStyle(NooBottomBarStyle style) {
|
void setBottomBarStyle(NooBottomBarStyle style) {
|
||||||
if (_bottomBarStyle == style) return;
|
if (_bottomBarStyle == style) return;
|
||||||
_bottomBarStyle = style;
|
_bottomBarStyle = style;
|
||||||
@@ -313,6 +399,34 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setFrostedBlur(double value) {
|
||||||
|
final v = value.clamp(minFrostedBlur, maxFrostedBlur);
|
||||||
|
if (_frostedBlur == v) return;
|
||||||
|
_frostedBlur = v;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setDouble(_prefFrostedBlur, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFrostedOpacity(double value) {
|
||||||
|
final v = value.clamp(minFrostedOpacity, maxFrostedOpacity);
|
||||||
|
if (_frostedOpacity == v) return;
|
||||||
|
_frostedOpacity = v;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setDouble(_prefFrostedOpacity, v));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFrostedPreset(FrostedGlassPreset preset) {
|
||||||
|
setFrostedBlur(preset.blur);
|
||||||
|
setFrostedOpacity(preset.opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBottomBarFrosted(bool value) {
|
||||||
|
if (_bottomBarFrosted == value) return;
|
||||||
|
_bottomBarFrosted = value;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
|
||||||
|
}
|
||||||
|
|
||||||
void setNavMenuStyle(NooNavMenuStyle style) {
|
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||||
if (_navMenuStyle == style) return;
|
if (_navMenuStyle == style) return;
|
||||||
_navMenuStyle = style;
|
_navMenuStyle = style;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
|
||||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||||
@@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart';
|
|||||||
class AppLockService {
|
class AppLockService {
|
||||||
static final LocalAuthentication _auth = LocalAuthentication();
|
static final LocalAuthentication _auth = LocalAuthentication();
|
||||||
|
|
||||||
|
/// Replace the platform prompt in tests (null = the real one).
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> Function(String reason)? debugAuthenticate;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static Future<bool> Function()? debugIsDeviceSupported;
|
||||||
|
|
||||||
/// Whether this device can do *some* form of local auth - biometric
|
/// Whether this device can do *some* form of local auth - biometric
|
||||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||||
static Future<bool> isDeviceSupported() async {
|
static Future<bool> isDeviceSupported() async {
|
||||||
|
if (debugIsDeviceSupported != null) return debugIsDeviceSupported!();
|
||||||
try {
|
try {
|
||||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||||
if (canCheckBiometrics) return true;
|
if (canCheckBiometrics) return true;
|
||||||
@@ -24,6 +33,7 @@ class AppLockService {
|
|||||||
/// throws) on cancellation, failure, or any platform error, so callers
|
/// throws) on cancellation, failure, or any platform error, so callers
|
||||||
/// can treat every non-true result the same way: stay locked/blocked.
|
/// can treat every non-true result the same way: stay locked/blocked.
|
||||||
static Future<bool> authenticate(String reason) async {
|
static Future<bool> authenticate(String reason) async {
|
||||||
|
if (debugAuthenticate != null) return debugAuthenticate!(reason);
|
||||||
try {
|
try {
|
||||||
return await _auth.authenticate(
|
return await _auth.authenticate(
|
||||||
localizedReason: reason,
|
localizedReason: reason,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class DownloadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startDownload', {...args, 'files': filesJson});
|
await invokeOrExplain(_channel, 'startDownload', 'Downloading', {
|
||||||
|
...args,
|
||||||
|
'files': filesJson,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
import '../providers/session_controller.dart';
|
import '../providers/session_controller.dart';
|
||||||
|
|
||||||
/// The "get this account's auth header, or bail" guard every native-
|
/// The "get this account's auth header, or bail" guard every native-
|
||||||
@@ -21,3 +22,57 @@ Map<String, String> baseChannelArgs(SessionController session) {
|
|||||||
'authHeader': requireAuthHeader(session),
|
'authHeader': requireAuthHeader(session),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Thrown by user-triggered native services (upload, download, sync now...)
|
||||||
|
/// on a platform whose native side isn't implemented yet - the message is
|
||||||
|
/// shown to the user as-is, e.g. in a snackbar.
|
||||||
|
class NativeServiceUnavailable implements Exception {
|
||||||
|
final String feature;
|
||||||
|
|
||||||
|
const NativeServiceUnavailable(this.feature);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => "$feature isn't available on this platform yet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calls [method], treating a missing native implementation (no
|
||||||
|
/// `MethodChannel` handler registered, as on iOS until each service is
|
||||||
|
/// built there) as "nothing to report": returns null instead of throwing
|
||||||
|
/// `MissingPluginException`. For cold-start checks, scheduling and other
|
||||||
|
/// calls nobody is waiting on.
|
||||||
|
Future<T?> invokeIfAvailable<T>(
|
||||||
|
MethodChannel channel,
|
||||||
|
String method, [
|
||||||
|
Object? arguments,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
return await channel.invokeMethod<T>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [invokeIfAvailable], but for an action the user just asked for:
|
||||||
|
/// throws [NativeServiceUnavailable] naming [feature] rather than silently
|
||||||
|
/// doing nothing.
|
||||||
|
Future<T?> invokeOrExplain<T>(
|
||||||
|
MethodChannel channel,
|
||||||
|
String method,
|
||||||
|
String feature, [
|
||||||
|
Object? arguments,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
return await channel.invokeMethod<T>(method, arguments);
|
||||||
|
} on MissingPluginException {
|
||||||
|
throw NativeServiceUnavailable(feature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [channel]'s broadcast stream mapped through [convert], with the
|
||||||
|
/// "no native implementation" error dropped - the stream just never emits.
|
||||||
|
Stream<T> quietEvents<T>(EventChannel channel, T Function(dynamic) convert) {
|
||||||
|
return channel
|
||||||
|
.receiveBroadcastStream()
|
||||||
|
.handleError((Object _) {}, test: (e) => e is MissingPluginException)
|
||||||
|
.map(convert);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import '../models/pick_request.dart';
|
import '../models/pick_request.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
/// Talks to `MainActivity.kt`'s hand-rolled GET_CONTENT picker handling
|
||||||
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
/// (mirrors `ShareIntentService`'s split between a cold-start check and a
|
||||||
@@ -14,7 +15,8 @@ class PickIntentService {
|
|||||||
/// Non-null if the app was launched cold as another app's file/photo
|
/// Non-null if the app was launched cold as another app's file/photo
|
||||||
/// picker.
|
/// picker.
|
||||||
static Future<PickRequest?> getPickRequest() async {
|
static Future<PickRequest?> getPickRequest() async {
|
||||||
final result = await _methodChannel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getPickRequest',
|
'getPickRequest',
|
||||||
);
|
);
|
||||||
return result == null ? null : PickRequest.fromMap(result);
|
return result == null ? null : PickRequest.fromMap(result);
|
||||||
@@ -23,7 +25,8 @@ class PickIntentService {
|
|||||||
/// Emits whenever the app is asked to act as a picker while already
|
/// Emits whenever the app is asked to act as a picker while already
|
||||||
/// running.
|
/// running.
|
||||||
static Stream<PickRequest> get onNewPickRequest {
|
static Stream<PickRequest> get onNewPickRequest {
|
||||||
return _newPickChannel.receiveBroadcastStream().map(
|
return quietEvents(
|
||||||
|
_newPickChannel,
|
||||||
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
(event) => PickRequest.fromMap(event as Map<dynamic, dynamic>),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ class PickIntentService {
|
|||||||
List<String> localPaths,
|
List<String> localPaths,
|
||||||
List<String> mimeTypes,
|
List<String> mimeTypes,
|
||||||
) {
|
) {
|
||||||
return _methodChannel.invokeMethod('finishPick', {
|
return invokeIfAvailable(_methodChannel, 'finishPick', {
|
||||||
'paths': localPaths,
|
'paths': localPaths,
|
||||||
'mimeTypes': mimeTypes,
|
'mimeTypes': mimeTypes,
|
||||||
});
|
});
|
||||||
@@ -42,6 +45,6 @@ class PickIntentService {
|
|||||||
|
|
||||||
/// Backs out of picking mode with no result, closing the picker.
|
/// Backs out of picking mode with no result, closing the picker.
|
||||||
static Future<void> cancelPick() {
|
static Future<void> cancelPick() {
|
||||||
return _methodChannel.invokeMethod('cancelPick');
|
return invokeIfAvailable(_methodChannel, 'cancelPick');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
|
import '../providers/session_controller.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
|
/// One account as the iOS Share Extension needs it - plain data, so the
|
||||||
|
/// payload builder is testable without a session.
|
||||||
|
class ShareAccountEntry {
|
||||||
|
final String id;
|
||||||
|
final String serverUrl;
|
||||||
|
final String username;
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
|
||||||
|
final String hiddenFilter;
|
||||||
|
|
||||||
|
/// The account's Files storage scope: `cloud`, `external` or `all`.
|
||||||
|
final String storageScope;
|
||||||
|
|
||||||
|
const ShareAccountEntry({
|
||||||
|
required this.id,
|
||||||
|
required this.serverUrl,
|
||||||
|
required this.username,
|
||||||
|
required this.password,
|
||||||
|
required this.hiddenFilter,
|
||||||
|
required this.storageScope,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells the native side which accounts can upload, which one is active, and
|
||||||
|
/// the app-lock settings, so iOS's Share Extension - a separate process with
|
||||||
|
/// no Flutter engine - can offer an account picker, list folders and upload
|
||||||
|
/// as them without opening the app (`ios/Runner/Native/NativeServices.swift`'s
|
||||||
|
/// `share_account`, stored in a Keychain group shared with the extension).
|
||||||
|
/// Android has no equivalent (its share intent opens the app itself), so there
|
||||||
|
/// the channel simply doesn't exist and everything here is a no-op.
|
||||||
|
class ShareAccountService {
|
||||||
|
static const _channel = MethodChannel('dev.ayushya.noo/share_account');
|
||||||
|
|
||||||
|
static String basicAuth(String username, String password) =>
|
||||||
|
'Basic ${base64Encode(utf8.encode('$username:$password'))}';
|
||||||
|
|
||||||
|
/// The JSON `SharedAccounts` (`ios/Shared/SharedAccount.swift`) decodes.
|
||||||
|
static String buildPayload({
|
||||||
|
required List<ShareAccountEntry> accounts,
|
||||||
|
required String? activeId,
|
||||||
|
required bool loginLockEnabled,
|
||||||
|
required bool lockAccountSwitching,
|
||||||
|
required bool lockHiddenFiles,
|
||||||
|
}) {
|
||||||
|
return jsonEncode({
|
||||||
|
'accounts': [
|
||||||
|
for (final a in accounts)
|
||||||
|
{
|
||||||
|
'id': a.id,
|
||||||
|
'serverUrl': a.serverUrl,
|
||||||
|
'username': a.username,
|
||||||
|
'authHeader': basicAuth(a.username, a.password),
|
||||||
|
'displayName':
|
||||||
|
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
||||||
|
'hiddenFilter': a.hiddenFilter,
|
||||||
|
'storageScope': a.storageScope,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'activeId': activeId,
|
||||||
|
'loginLockEnabled': loginLockEnabled,
|
||||||
|
'lockAccountSwitching': lockAccountSwitching,
|
||||||
|
'lockHiddenFiles': lockHiddenFiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publishes every saved account that can actually upload (has a stored
|
||||||
|
/// password and isn't signed out), with the active one's hidden-files
|
||||||
|
/// filter and storage scope taken live from [files] - the saved prefs are
|
||||||
|
/// written asynchronously, so they can lag a change that just happened.
|
||||||
|
static Future<void> publish(
|
||||||
|
SessionController session,
|
||||||
|
FilesController files,
|
||||||
|
) async {
|
||||||
|
final activeId = session.activeAccountId;
|
||||||
|
if (activeId == null) return clear();
|
||||||
|
|
||||||
|
final prefs = await session.prefsFuture;
|
||||||
|
final store = session.accountStore;
|
||||||
|
final signedOut = store.loadSignedOut(prefs);
|
||||||
|
final entries = <ShareAccountEntry>[];
|
||||||
|
for (final account in session.accounts) {
|
||||||
|
if (signedOut.contains(account.id)) {
|
||||||
|
debugPrint('[ShareAccounts] skipping ${account.id}: signed out');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final password = await store.readPassword(account.id);
|
||||||
|
if (password == null) {
|
||||||
|
debugPrint(
|
||||||
|
'[ShareAccounts] skipping ${account.id}: no stored password',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final isActive = account.id == activeId;
|
||||||
|
final filter = isActive
|
||||||
|
? files.hiddenFilter
|
||||||
|
: FilesController.savedHiddenFilter(
|
||||||
|
prefs,
|
||||||
|
store.accountPrefKey,
|
||||||
|
account.id,
|
||||||
|
);
|
||||||
|
final scope = isActive
|
||||||
|
? files.storageScope
|
||||||
|
: FilesController.savedStorageScope(
|
||||||
|
prefs,
|
||||||
|
store.accountPrefKey,
|
||||||
|
account.id,
|
||||||
|
);
|
||||||
|
entries.add(
|
||||||
|
ShareAccountEntry(
|
||||||
|
id: account.id,
|
||||||
|
serverUrl: account.serverUrl,
|
||||||
|
username: account.username,
|
||||||
|
password: password,
|
||||||
|
hiddenFilter: filter.name,
|
||||||
|
storageScope: scope.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
debugPrint(
|
||||||
|
'[ShareAccounts] publishing ${entries.length} of '
|
||||||
|
'${session.accounts.length} saved accounts, active=$activeId '
|
||||||
|
'(${signedOut.length} signed out)',
|
||||||
|
);
|
||||||
|
if (entries.isEmpty) return clear();
|
||||||
|
|
||||||
|
await invokeIfAvailable(_channel, 'setAccounts', {
|
||||||
|
'accounts': buildPayload(
|
||||||
|
accounts: entries,
|
||||||
|
activeId: activeId,
|
||||||
|
loginLockEnabled: session.loginLockEnabled,
|
||||||
|
lockAccountSwitching: session.lockAccountSwitching,
|
||||||
|
lockHiddenFiles: session.lockHiddenFiles,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets everything (sign-out) so the extension stops offering uploads.
|
||||||
|
static Future<void> clear() => invokeIfAvailable(_channel, 'clearAccount');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps [ShareAccountService]'s published data current while the app is
|
||||||
|
/// running: republishes when an account becomes ready or when anything the
|
||||||
|
/// extension depends on changes - the account list or active account, the
|
||||||
|
/// lock settings, or the active account's hidden-files filter.
|
||||||
|
class ShareAccountSync {
|
||||||
|
final SessionController session;
|
||||||
|
final FilesController files;
|
||||||
|
|
||||||
|
String? _lastSignature;
|
||||||
|
bool _disposed = false;
|
||||||
|
Future<void> _queue = Future.value();
|
||||||
|
|
||||||
|
ShareAccountSync(this.session, this.files);
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
session.addAccountReadyListener(_publish);
|
||||||
|
session.addListener(_onChange);
|
||||||
|
files.addListener(_onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
session.removeListener(_onChange);
|
||||||
|
files.removeListener(_onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _signature() => [
|
||||||
|
session.activeAccountId,
|
||||||
|
session.accounts.map((a) => a.id).join(','),
|
||||||
|
session.loginLockEnabled,
|
||||||
|
session.lockAccountSwitching,
|
||||||
|
session.lockHiddenFiles,
|
||||||
|
files.hiddenFilter.name,
|
||||||
|
files.storageScope.name,
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
void _onChange() {
|
||||||
|
if (_signature() != _lastSignature) _publish();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one at a time, in order, so a slow publish can't land after a
|
||||||
|
/// newer one and leave the extension with stale data.
|
||||||
|
void _publish() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_lastSignature = _signature();
|
||||||
|
_queue = _queue.then((_) async {
|
||||||
|
if (_disposed) return;
|
||||||
|
try {
|
||||||
|
await ShareAccountService.publish(session, files);
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort: the extension just keeps the previous data.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
/// A file shared to Noo from another app's "Share to..." sheet, before its
|
||||||
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
/// bytes have been touched - just the `content://` Uri and whatever cheap
|
||||||
@@ -40,7 +41,8 @@ class ShareIntentService {
|
|||||||
/// Whatever was shared to launch the app cold (empty if it was launched
|
/// Whatever was shared to launch the app cold (empty if it was launched
|
||||||
/// normally, not via a share).
|
/// normally, not via a share).
|
||||||
static Future<List<SharedFileRef>> getInitialShare() async {
|
static Future<List<SharedFileRef>> getInitialShare() async {
|
||||||
final result = await _methodChannel.invokeMethod<List<dynamic>>(
|
final result = await invokeIfAvailable<List<dynamic>>(
|
||||||
|
_methodChannel,
|
||||||
'getInitialShare',
|
'getInitialShare',
|
||||||
);
|
);
|
||||||
return (result ?? [])
|
return (result ?? [])
|
||||||
@@ -51,7 +53,7 @@ class ShareIntentService {
|
|||||||
|
|
||||||
/// Emits whenever another share arrives while the app is already running.
|
/// Emits whenever another share arrives while the app is already running.
|
||||||
static Stream<List<SharedFileRef>> get onNewShare {
|
static Stream<List<SharedFileRef>> get onNewShare {
|
||||||
return _newShareChannel.receiveBroadcastStream().map((event) {
|
return quietEvents(_newShareChannel, (event) {
|
||||||
return (event as List<dynamic>)
|
return (event as List<dynamic>)
|
||||||
.cast<Map<dynamic, dynamic>>()
|
.cast<Map<dynamic, dynamic>>()
|
||||||
.map(SharedFileRef.fromMap)
|
.map(SharedFileRef.fromMap)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../models/saved_account.dart';
|
|||||||
import '../providers/files_controller.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';
|
||||||
|
import 'native_channel.dart';
|
||||||
|
|
||||||
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
/// A snapshot of native device-sync status - see `SyncStatusBus.kt` (the
|
||||||
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
/// in-process pub/sub it mirrors) and `MainActivity.kt`'s `syncStatusMap`.
|
||||||
@@ -104,13 +105,18 @@ class SyncService {
|
|||||||
)
|
)
|
||||||
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
: _SyncConfig.fromPrefs(prefs, store.accountPrefKey, account.id);
|
||||||
final password = await store.readPassword(account.id);
|
final password = await store.readPassword(account.id);
|
||||||
if (password == null ||
|
if (password == null || config.paths.isEmpty) {
|
||||||
config.paths.isEmpty ||
|
|
||||||
config.intervalMinutes == null) {
|
|
||||||
await cancelAccount(account.id);
|
await cancelAccount(account.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _channel.invokeMethod('reschedule', {
|
if (config.intervalMinutes == null) {
|
||||||
|
// Background sync is off, but this account still has paths to sync
|
||||||
|
// by hand: keep its credentials (native uses them for "Sync now"
|
||||||
|
// and conflict notification actions).
|
||||||
|
await cancelAccount(account.id, forget: false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await invokeIfAvailable(_channel, 'reschedule', {
|
||||||
'accountId': account.id,
|
'accountId': account.id,
|
||||||
'serverUrl': account.serverUrl,
|
'serverUrl': account.serverUrl,
|
||||||
'username': account.username,
|
'username': account.username,
|
||||||
@@ -131,8 +137,17 @@ class SyncService {
|
|||||||
|
|
||||||
/// Stops [accountId]'s periodic job (the account was removed, or has
|
/// Stops [accountId]'s periodic job (the account was removed, or has
|
||||||
/// nothing left to sync).
|
/// nothing left to sync).
|
||||||
static Future<void> cancelAccount(String accountId) async {
|
///
|
||||||
await _channel.invokeMethod('cancel', {'accountId': accountId});
|
/// [forget] (the default) is for an account that was signed out or removed:
|
||||||
|
/// native also drops its stored credentials. Pass false when only the
|
||||||
|
/// background job is being turned off - the account's paths are still
|
||||||
|
/// synced by hand ("Sync now"), and a conflict notification's actions
|
||||||
|
/// still need its credentials.
|
||||||
|
static Future<void> cancelAccount(String accountId, {bool forget = true}) async {
|
||||||
|
await invokeIfAvailable(_channel, 'cancel', {
|
||||||
|
'accountId': accountId,
|
||||||
|
'forget': forget,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs a one-off sync pass immediately, independent of the periodic
|
/// Runs a one-off sync pass immediately, independent of the periodic
|
||||||
@@ -158,7 +173,7 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
final paths = sync.syncEverything ? ['/'] : sync.syncedPaths;
|
||||||
await _channel.invokeMethod('syncNow', {
|
await invokeOrExplain(_channel, 'syncNow', 'Syncing', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -178,7 +193,8 @@ class SyncService {
|
|||||||
/// app start - the native bus only knows an account once a sync pass has
|
/// app start - the native bus only knows an account once a sync pass has
|
||||||
/// run in this process.
|
/// run in this process.
|
||||||
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
static Future<SyncStatusSnapshot> getStatus(String? accountId) async {
|
||||||
final result = await _channel.invokeMethod<Map<dynamic, dynamic>>(
|
final result = await invokeIfAvailable<Map<dynamic, dynamic>>(
|
||||||
|
_channel,
|
||||||
'getSyncStatus',
|
'getSyncStatus',
|
||||||
{'accountId': accountId},
|
{'accountId': accountId},
|
||||||
);
|
);
|
||||||
@@ -196,11 +212,10 @@ class SyncService {
|
|||||||
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
/// (`SyncStatusController`), so sync badges stopped updating until the app
|
||||||
/// was restarted. This is a broadcast stream, so any number of Dart
|
/// was restarted. This is a broadcast stream, so any number of Dart
|
||||||
/// listeners share the one native subscription.
|
/// listeners share the one native subscription.
|
||||||
static final Stream<SyncStatusSnapshot> statusStream = _statusChannel
|
static final Stream<SyncStatusSnapshot> statusStream = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map(
|
(event) => SyncStatusSnapshot.fromMap(event as Map<dynamic, dynamic>),
|
||||||
(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`
|
||||||
@@ -213,7 +228,7 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final authHeader = session.service?.authHeaders['Authorization'];
|
final authHeader = session.service?.authHeaders['Authorization'];
|
||||||
if (authHeader == null) throw Exception('Not logged in.');
|
if (authHeader == null) throw Exception('Not logged in.');
|
||||||
await _channel.invokeMethod('resolveConflict', {
|
await invokeOrExplain(_channel, 'resolveConflict', 'Resolving conflicts', {
|
||||||
'accountId': conflict.accountId,
|
'accountId': conflict.accountId,
|
||||||
'serverUrl': session.serverUrl,
|
'serverUrl': session.serverUrl,
|
||||||
'username': session.username,
|
'username': session.username,
|
||||||
@@ -236,12 +251,23 @@ class SyncService {
|
|||||||
) async {
|
) async {
|
||||||
final accountId = session.activeAccountId;
|
final accountId = session.activeAccountId;
|
||||||
if (accountId == null) return;
|
if (accountId == null) return;
|
||||||
await _channel.invokeMethod('removeLocalSync', {
|
await invokeIfAvailable(_channel, 'removeLocalSync', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'path': path,
|
'path': path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the `sync/<accountId>/...` mirror lives: Android's app-specific
|
||||||
|
/// external files dir (what `SyncEngine.kt` writes to); everywhere else -
|
||||||
|
/// iOS has no external storage, `getExternalStorageDirectory` throws
|
||||||
|
/// there - the app's private support directory.
|
||||||
|
static Future<Directory?> baseDirectory() async {
|
||||||
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||||
|
return getExternalStorageDirectory();
|
||||||
|
}
|
||||||
|
return getApplicationSupportDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
/// The deterministic local mirror path for [remoteItemPath] under
|
/// The deterministic local mirror path for [remoteItemPath] under
|
||||||
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
/// account [accountId] - mirrors `SyncEngine.kt#syncRoot`'s
|
||||||
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
/// `<externalFilesDir>/sync/<accountId>/...` layout exactly, so this
|
||||||
@@ -251,7 +277,7 @@ class SyncService {
|
|||||||
String accountId,
|
String accountId,
|
||||||
String remoteItemPath,
|
String remoteItemPath,
|
||||||
) async {
|
) async {
|
||||||
final base = await getExternalStorageDirectory();
|
final base = await baseDirectory();
|
||||||
if (base == null) return null;
|
if (base == null) return null;
|
||||||
final relPath = remoteItemPath.startsWith('/')
|
final relPath = remoteItemPath.startsWith('/')
|
||||||
? remoteItemPath.substring(1)
|
? remoteItemPath.substring(1)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class UploadService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _channel.invokeMethod('startUpload', {
|
await invokeOrExplain(_channel, 'startUpload', 'Uploading', {
|
||||||
...args,
|
...args,
|
||||||
'files': filesJson,
|
'files': filesJson,
|
||||||
'remoteFolder': files.currentFolderPath,
|
'remoteFolder': files.currentFolderPath,
|
||||||
@@ -70,7 +70,8 @@ class UploadService {
|
|||||||
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
/// deliberately: see `SyncService.statusStream`'s identical doc comment -
|
||||||
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
/// `EventChannel.receiveBroadcastStream()` opens its own native
|
||||||
/// subscription per call, and the native side only keeps the latest one.
|
/// subscription per call, and the native side only keeps the latest one.
|
||||||
static final Stream<UploadCompletion> completions = _statusChannel
|
static final Stream<UploadCompletion> completions = quietEvents(
|
||||||
.receiveBroadcastStream()
|
_statusChannel,
|
||||||
.map((event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>));
|
(event) => UploadCompletion.fromMap(event as Map<dynamic, dynamic>),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
|||||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||||
|
///
|
||||||
|
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||||
|
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||||
|
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||||
|
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||||
|
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||||
|
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||||
|
/// off the page.
|
||||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
final dark = cs.brightness == Brightness.dark;
|
||||||
|
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||||
return NooColors(
|
return NooColors(
|
||||||
bg: cs.surface,
|
bg: cs.surface,
|
||||||
surface: cs.surfaceContainerLow,
|
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||||
surface2: cs.surfaceContainerHigh,
|
surface2: cs.surfaceContainerHigh,
|
||||||
surface3: cs.surfaceContainerHighest,
|
surface3: cs.surfaceContainerHighest,
|
||||||
line: cs.outlineVariant,
|
line: cs.outlineVariant,
|
||||||
@@ -357,8 +366,8 @@ class NooMotion {
|
|||||||
|
|
||||||
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
||||||
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
||||||
/// bar/search satellite (the only other thing in the app that's genuinely
|
/// bar/search satellite, and the floating selection action bar.
|
||||||
/// floating above other content). Nothing else should use a `BoxShadow`.
|
/// Nothing else should use a `BoxShadow`.
|
||||||
const nooDialogShadow = BoxShadow(
|
const nooDialogShadow = BoxShadow(
|
||||||
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
||||||
blurRadius: 48,
|
blurRadius: 48,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
|||||||
import '../widgets/noo/core/noo_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/selection_bar_overlay.dart';
|
||||||
import '../widgets/tabs/tab_state_slivers.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
@@ -54,8 +55,14 @@ class FavoritesView extends StatefulWidget {
|
|||||||
/// This tab's own shell top bar, planted as its first sliver - see
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
final PreferredSizeWidget? topBar;
|
final PreferredSizeWidget? topBar;
|
||||||
|
final ValueChanged<bool>? onSelectionChanged;
|
||||||
|
|
||||||
const FavoritesView({super.key, required this.scrollController, this.topBar});
|
const FavoritesView({
|
||||||
|
super.key,
|
||||||
|
required this.scrollController,
|
||||||
|
this.topBar,
|
||||||
|
this.onSelectionChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FavoritesView> createState() => _FavoritesViewState();
|
State<FavoritesView> createState() => _FavoritesViewState();
|
||||||
@@ -72,10 +79,12 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(_isSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
|
widget.onSelectionChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A favorited folder switches to the Files tab, navigated there; a
|
/// A favorited folder switches to the Files tab, navigated there; a
|
||||||
@@ -483,6 +492,10 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selectionAtBottom =
|
||||||
|
_isSelecting &&
|
||||||
|
context.watch<SettingsController>().selectionBarPosition ==
|
||||||
|
SelectionBarPosition.bottom;
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final isDesktop = NooLayout.isDesktop(context);
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
final favoritesController = context.watch<FavoritesController>();
|
final favoritesController = context.watch<FavoritesController>();
|
||||||
@@ -513,15 +526,17 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
|
|
||||||
final contentSlivers = <Widget>[
|
final contentSlivers = <Widget>[
|
||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
SliverPersistentHeader(
|
if (!selectionAtBottom)
|
||||||
pinned: !_isSelecting,
|
SliverPersistentHeader(
|
||||||
delegate: StickyHeaderDelegate(
|
pinned: true,
|
||||||
height: _isSelecting ? 56 : 64,
|
delegate: StickyHeaderDelegate(
|
||||||
child: _isSelecting
|
floating: _isSelecting,
|
||||||
? _buildSelectionBar(context, selectedItems)
|
height: _isSelecting ? 56 : 64,
|
||||||
: controlsRow,
|
child: _isSelecting
|
||||||
|
? _buildSelectionBar(context, selectedItems)
|
||||||
|
: controlsRow,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (favoritesController.isLoading && favorites.isEmpty)
|
if (favoritesController.isLoading && favorites.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
@@ -646,7 +661,13 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
}, childCount: favorites.length),
|
}, childCount: favorites.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height:
|
||||||
|
bottomBarClearance(context) +
|
||||||
|
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -654,24 +675,29 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelecting) _clearSelection();
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
},
|
},
|
||||||
child: ColoredBox(
|
child: SelectionBarOverlay(
|
||||||
color: colors.bg,
|
bar: selectionAtBottom
|
||||||
child: RefreshIndicator(
|
? _buildSelectionBar(context, selectedItems)
|
||||||
color: colors.accent,
|
: null,
|
||||||
backgroundColor: colors.surface,
|
child: ColoredBox(
|
||||||
onRefresh: favoritesController.fetchAll,
|
color: colors.bg,
|
||||||
// See files_view.dart's identical fix - without this, the sticky
|
child: RefreshIndicator(
|
||||||
// controls row rides up under the status bar once the floating
|
color: colors.accent,
|
||||||
// top bar above it fully collapses.
|
backgroundColor: colors.surface,
|
||||||
child: SafeArea(
|
onRefresh: favoritesController.fetchAll,
|
||||||
top: true,
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
bottom: false,
|
// controls row rides up under the status bar once the floating
|
||||||
child: CustomScrollView(
|
// top bar above it fully collapses.
|
||||||
controller: widget.scrollController,
|
child: SafeArea(
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
top: true,
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
bottom: false,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
child: CustomScrollView(
|
||||||
slivers: contentSlivers,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
|
|
||||||
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
||||||
|
|
||||||
|
/// Edit/Preview + Save for a text/markdown file: the preview owns the
|
||||||
|
/// editing, this just lets the top bar draw (and trigger) its buttons.
|
||||||
|
final _textActions = TextPreviewController();
|
||||||
|
|
||||||
void _openDetails() {
|
void _openDetails() {
|
||||||
if (NooLayout.isDesktop(context)) {
|
if (NooLayout.isDesktop(context)) {
|
||||||
DetailsSheet.show(context, _currentItem);
|
DetailsSheet.show(context, _currentItem);
|
||||||
@@ -229,6 +233,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_textActions.dispose();
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -423,9 +428,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
// The stage follows the app's own theme (`bg`) rather than being pinned
|
// The stage follows the app's own theme (`bg`) rather than being pinned
|
||||||
// to black - a black stage in light mode read as jarringly out of place
|
// to black - a black stage in light mode read as jarringly out of place
|
||||||
// next to the rest of the light-themed app. The floating top/action
|
// next to the rest of the light-themed app. The floating top/action
|
||||||
// bars now follow the theme too (FrostedGlassContainer's own `surface`
|
// bars follow the theme and share the navigation frost preference.
|
||||||
// default), at a higher blur opacity to stay legible over arbitrary
|
|
||||||
// photo/video brightness underneath.
|
|
||||||
final stageColor = colors.bg;
|
final stageColor = colors.bg;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -532,7 +535,14 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
// Edit/Preview and Save for text/markdown files -
|
||||||
|
// empty (zero-width) for every other file type.
|
||||||
|
ListenableBuilder(
|
||||||
|
listenable: _textActions,
|
||||||
|
builder: (context, _) =>
|
||||||
|
_TextActions(controller: _textActions),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -731,6 +741,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
return MediaTextPreview(
|
return MediaTextPreview(
|
||||||
item: widget.item,
|
item: widget.item,
|
||||||
session: session,
|
session: session,
|
||||||
|
controller: _textActions,
|
||||||
localPathResolver: widget.localPathResolver,
|
localPathResolver: widget.localPathResolver,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -743,3 +754,103 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text viewer's top-bar buttons: Edit/Preview (markdown only) and Save
|
||||||
|
/// (once the content changed; a spinner while it saves). Same
|
||||||
|
/// round [NooButton] look (primary for the main action, secondary beside it).
|
||||||
|
class _TextActions extends StatelessWidget {
|
||||||
|
final TextPreviewController controller;
|
||||||
|
|
||||||
|
const _TextActions({required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final toggle = controller.canToggleEditing;
|
||||||
|
final save = controller.saving || controller.showSave;
|
||||||
|
// One button is the primary action; with two, Save is and Edit/Preview
|
||||||
|
// steps back to secondary.
|
||||||
|
final both = toggle && save;
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (toggle)
|
||||||
|
_ActionButton(
|
||||||
|
icon: controller.editing ? LucideIcons.eye : LucideIcons.pencil,
|
||||||
|
tooltip: controller.editing ? 'Preview' : 'Edit',
|
||||||
|
variant: both
|
||||||
|
? NooButtonVariant.secondary
|
||||||
|
: NooButtonVariant.primary,
|
||||||
|
onTap: controller.toggleEditing,
|
||||||
|
),
|
||||||
|
if (both) const SizedBox(width: 8),
|
||||||
|
if (controller.saving)
|
||||||
|
const _SavingIndicator()
|
||||||
|
else if (save)
|
||||||
|
_ActionButton(
|
||||||
|
icon: LucideIcons.save,
|
||||||
|
tooltip: 'Save',
|
||||||
|
variant: NooButtonVariant.primary,
|
||||||
|
onTap: controller.save,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A round icon-only [NooButton] with the tooltip and semantic label an
|
||||||
|
/// icon-only button needs.
|
||||||
|
class _ActionButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String tooltip;
|
||||||
|
final NooButtonVariant variant;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActionButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.variant,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Semantics(
|
||||||
|
button: true,
|
||||||
|
label: tooltip,
|
||||||
|
excludeSemantics: true,
|
||||||
|
child: NooButton(
|
||||||
|
icon: icon,
|
||||||
|
iconOnly: true,
|
||||||
|
variant: variant,
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands in for the Save button while it saves: the same primary circle,
|
||||||
|
/// with a spinner, so it can't be tapped twice.
|
||||||
|
class _SavingIndicator extends StatelessWidget {
|
||||||
|
const _SavingIndicator();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Semantics(
|
||||||
|
label: 'Saving',
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(color: colors.accent, shape: BoxShape.circle),
|
||||||
|
child: const SizedBox.square(
|
||||||
|
dimension: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import '../widgets/noo/noo_layout.dart';
|
|||||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/selection_bar_overlay.dart';
|
||||||
import '../widgets/tabs/tab_state_slivers.dart';
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
@@ -64,18 +65,71 @@ class FilesView extends StatefulWidget {
|
|||||||
/// This tab's own shell top bar, planted as its first sliver - see
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
final PreferredSizeWidget? topBar;
|
final PreferredSizeWidget? topBar;
|
||||||
|
final ValueChanged<bool>? onSelectionChanged;
|
||||||
|
|
||||||
const FilesView({
|
const FilesView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
this.offline = false,
|
this.offline = false,
|
||||||
this.topBar,
|
this.topBar,
|
||||||
|
this.onSelectionChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FilesView> createState() => _FilesViewState();
|
State<FilesView> createState() => _FilesViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The collapsible "External storage" header [StorageScope.all] puts above
|
||||||
|
/// the external items.
|
||||||
|
class _ExternalStorageHeader extends StatelessWidget {
|
||||||
|
final int count;
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ExternalStorageHeader({
|
||||||
|
required this.count,
|
||||||
|
required this.expanded,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
expanded: expanded,
|
||||||
|
label: 'External storage',
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'External storage',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('$count', style: NooText.body.copyWith(color: colors.fg3)),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(
|
||||||
|
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||||
|
size: 18,
|
||||||
|
color: colors.fg3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||||
/// whenever the folder changes (folder path + item id) so Flutter discards
|
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||||
/// and remounts the Element instead of just updating it in place - that's
|
/// and remounts the Element instead of just updating it in place - that's
|
||||||
@@ -236,6 +290,9 @@ class _GridThumbnail extends StatelessWidget {
|
|||||||
|
|
||||||
class _FilesViewState extends State<FilesView> {
|
class _FilesViewState extends State<FilesView> {
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
|
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||||
|
bool _externalExpanded = true;
|
||||||
int _lastPathDepth = 1;
|
int _lastPathDepth = 1;
|
||||||
|
|
||||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||||
@@ -245,9 +302,19 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
/// The folder data source for this tab. Watches the concrete controller
|
/// The folder data source for this tab. Watches the concrete controller
|
||||||
/// (that's what's registered as a provider), then hands it back as the
|
/// (that's what's registered as a provider), then hands it back as the
|
||||||
/// [FolderBrowser] the rest of the view actually needs.
|
/// [FolderBrowser] the rest of the view actually needs.
|
||||||
FolderBrowser _browserOf(BuildContext context) => _offline
|
///
|
||||||
? context.watch<OfflineController>()
|
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||||
: context.watch<FilesController>();
|
/// of `build` asserts in debug builds (silently swallowing the tap).
|
||||||
|
FolderBrowser _browserOf(BuildContext context, {bool listen = true}) {
|
||||||
|
if (listen) {
|
||||||
|
return _offline
|
||||||
|
? context.watch<OfflineController>()
|
||||||
|
: context.watch<FilesController>();
|
||||||
|
}
|
||||||
|
return _offline
|
||||||
|
? context.read<OfflineController>()
|
||||||
|
: context.read<FilesController>();
|
||||||
|
}
|
||||||
|
|
||||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
/// The on-device copy of [item] for the Offline tab; null online, where
|
||||||
/// thumbnails come from server previews instead.
|
/// thumbnails come from server previews instead.
|
||||||
@@ -273,10 +340,12 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(_isSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
|
widget.onSelectionChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Routes a tap on an item, in picking, selecting or plain-browsing mode
|
/// Routes a tap on an item, in picking, selecting or plain-browsing mode
|
||||||
@@ -287,7 +356,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
NextcloudItem item, {
|
NextcloudItem item, {
|
||||||
required bool picking,
|
required bool picking,
|
||||||
}) {
|
}) {
|
||||||
final browser = _browserOf(context);
|
final browser = _browserOf(context, listen: false);
|
||||||
if (picking) {
|
if (picking) {
|
||||||
_handlePickTap(context, item);
|
_handlePickTap(context, item);
|
||||||
} else if (_isSelecting) {
|
} else if (_isSelecting) {
|
||||||
@@ -588,6 +657,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selectionAtBottom =
|
||||||
|
_isSelecting &&
|
||||||
|
context.watch<SettingsController>().selectionBarPosition ==
|
||||||
|
SelectionBarPosition.bottom;
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
// Display prefs (grid/list, filters, sort) live on FilesController for
|
// Display prefs (grid/list, filters, sort) live on FilesController for
|
||||||
// both tabs; `browser` is the tab's own folder listing.
|
// both tabs; `browser` is the tab's own folder listing.
|
||||||
@@ -659,23 +732,149 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// One sliver group per layout (grid / desktop table / mobile list) for
|
||||||
|
// an arbitrary slice of items - [StorageScope.all] renders two of them,
|
||||||
|
// the regular items and then the external ones under a collapsible
|
||||||
|
// header, instead of one list over `browser.items`.
|
||||||
|
List<Widget> itemSlivers(
|
||||||
|
List<NextcloudItem> items, {
|
||||||
|
required String section,
|
||||||
|
bool tableHeader = true,
|
||||||
|
}) {
|
||||||
|
if (files.isGridView) {
|
||||||
|
return [
|
||||||
|
SliverPadding(
|
||||||
|
key: ValueKey('files-grid-$section'),
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
gutter,
|
||||||
|
NooSpace.xs,
|
||||||
|
gutter,
|
||||||
|
NooSpace.lg,
|
||||||
|
),
|
||||||
|
sliver: SliverGrid(
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: isDesktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
|
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||||
|
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||||
|
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||||
|
),
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _FolderEnterAnimation(
|
||||||
|
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||||
|
index: index,
|
||||||
|
fromRight: navigatingDeeper,
|
||||||
|
child: _buildGridCard(context, item),
|
||||||
|
);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (isDesktop) {
|
||||||
|
return [
|
||||||
|
if (tableHeader)
|
||||||
|
SliverPadding(
|
||||||
|
key: const ValueKey('files-table-header'),
|
||||||
|
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: _buildDesktopHeader(
|
||||||
|
context,
|
||||||
|
files,
|
||||||
|
browser.currentFolderPath,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
key: ValueKey('files-table-$section'),
|
||||||
|
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _FolderEnterAnimation(
|
||||||
|
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||||
|
index: index,
|
||||||
|
fromRight: navigatingDeeper,
|
||||||
|
child: _buildDesktopRow(context, item),
|
||||||
|
);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
SliverPadding(
|
||||||
|
key: ValueKey('files-list-$section'),
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xs,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.lg,
|
||||||
|
),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return _FolderEnterAnimation(
|
||||||
|
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||||
|
index: index,
|
||||||
|
fromRight: navigatingDeeper,
|
||||||
|
child: _buildMobileRow(context, item, index, items.length),
|
||||||
|
);
|
||||||
|
}, childCount: items.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// "All + external": external items leave the main list for their own
|
||||||
|
// collapsible section below it. Offline has no such distinction.
|
||||||
|
final splitExternal = !_offline && files.storageScope == StorageScope.all;
|
||||||
|
final mainItems = splitExternal
|
||||||
|
? browser.items.where((i) => !i.isExternalStorage).toList()
|
||||||
|
: browser.items;
|
||||||
|
final externalItems = splitExternal
|
||||||
|
? browser.items.where((i) => i.isExternalStorage).toList()
|
||||||
|
: const <NextcloudItem>[];
|
||||||
|
final bodySlivers = <Widget>[
|
||||||
|
if (mainItems.isNotEmpty) ...itemSlivers(mainItems, section: 'main'),
|
||||||
|
if (externalItems.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _ExternalStorageHeader(
|
||||||
|
count: externalItems.length,
|
||||||
|
expanded: _externalExpanded,
|
||||||
|
onTap: () => setState(() => _externalExpanded = !_externalExpanded),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_externalExpanded)
|
||||||
|
...itemSlivers(
|
||||||
|
externalItems,
|
||||||
|
section: 'external',
|
||||||
|
tableHeader: mainItems.isEmpty,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Pinned in both states - while browsing this is the controls row
|
// Pinned in both states - while browsing this is the controls row
|
||||||
// (+ breadcrumbs), while selecting it's the selection bar (see
|
// (+ breadcrumbs), while selecting it's the selection bar (see
|
||||||
// `topRow` above): either way it's the one thing that always stays
|
// `topRow` above): either way it's the one thing that always stays
|
||||||
// at the very top of the scroll view.
|
// at the very top of the scroll view.
|
||||||
SliverPersistentHeader(
|
if (!selectionAtBottom)
|
||||||
pinned: true,
|
SliverPersistentHeader(
|
||||||
delegate: StickyHeaderDelegate(
|
pinned: true,
|
||||||
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
|
delegate: StickyHeaderDelegate(
|
||||||
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
|
floating: _isSelecting,
|
||||||
// Getting this wrong overflows the sliver header by exactly the
|
// 20 (topRow's own top+bottom padding) + 44 (FilesControlsRow's
|
||||||
// shortfall - a real bug this shipped with once already.
|
// fixed height) [+ 10 gap + 32 breadcrumbs height, if present].
|
||||||
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
|
// Getting this wrong overflows the sliver header by exactly the
|
||||||
child: topRow,
|
// shortfall - a real bug this shipped with once already.
|
||||||
|
height: _isSelecting ? 56 : (hasBreadcrumbs ? 106 : 64),
|
||||||
|
child: topRow,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (_offline)
|
if (_offline)
|
||||||
SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)),
|
SliverToBoxAdapter(child: _buildOfflineSummary(context, sync)),
|
||||||
// Files List / Grid
|
// Files List / Grid
|
||||||
@@ -743,96 +942,24 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (files.isGridView)
|
else
|
||||||
SliverPadding(
|
...bodySlivers,
|
||||||
key: const ValueKey('files-grid'),
|
|
||||||
padding: EdgeInsets.fromLTRB(
|
|
||||||
gutter,
|
|
||||||
NooSpace.xs,
|
|
||||||
gutter,
|
|
||||||
NooSpace.lg,
|
|
||||||
),
|
|
||||||
sliver: SliverGrid(
|
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: isDesktop
|
|
||||||
? 5
|
|
||||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
|
||||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
|
||||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
|
||||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = browser.items[index];
|
|
||||||
return _FolderEnterAnimation(
|
|
||||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
|
||||||
index: index,
|
|
||||||
fromRight: navigatingDeeper,
|
|
||||||
child: _buildGridCard(context, item),
|
|
||||||
);
|
|
||||||
}, childCount: browser.items.length),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else if (isDesktop) ...[
|
|
||||||
SliverPadding(
|
|
||||||
key: const ValueKey('files-table-header'),
|
|
||||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
|
||||||
sliver: SliverToBoxAdapter(
|
|
||||||
child: _buildDesktopHeader(
|
|
||||||
context,
|
|
||||||
files,
|
|
||||||
browser.currentFolderPath,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverPadding(
|
|
||||||
key: const ValueKey('files-table'),
|
|
||||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = browser.items[index];
|
|
||||||
return _FolderEnterAnimation(
|
|
||||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
|
||||||
index: index,
|
|
||||||
fromRight: navigatingDeeper,
|
|
||||||
child: _buildDesktopRow(context, item),
|
|
||||||
);
|
|
||||||
}, childCount: browser.items.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] else
|
|
||||||
SliverPadding(
|
|
||||||
key: const ValueKey('files-list'),
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
NooSpace.sm,
|
|
||||||
NooSpace.xs,
|
|
||||||
NooSpace.sm,
|
|
||||||
NooSpace.lg,
|
|
||||||
),
|
|
||||||
sliver: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
|
||||||
final item = browser.items[index];
|
|
||||||
return _FolderEnterAnimation(
|
|
||||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
|
||||||
index: index,
|
|
||||||
fromRight: navigatingDeeper,
|
|
||||||
child: _buildMobileRow(
|
|
||||||
context,
|
|
||||||
item,
|
|
||||||
index,
|
|
||||||
browser.items.length,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, childCount: browser.items.length),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||||
// once Scaffold shrinks the body above an attached bar, but floating
|
// once Scaffold shrinks the body above an attached bar, but floating
|
||||||
// draws the body behind the bar instead, so it needs the bar's own
|
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||||
// footprint added on top or the last row ends up under it.
|
// needs the bar's own footprint added on top or the last row ends up
|
||||||
if (context.watch<SettingsController>().bottomBarStyle ==
|
// under it.
|
||||||
NooBottomBarStyle.floating)
|
if (selectionAtBottom ||
|
||||||
|
NooBottomBar.drawsBehindBody(
|
||||||
|
context.watch<SettingsController>().bottomBarStyle,
|
||||||
|
context.watch<SettingsController>().bottomBarFrosted,
|
||||||
|
))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: bottomBarClearance(context)),
|
child: SizedBox(
|
||||||
|
height:
|
||||||
|
bottomBarClearance(context) +
|
||||||
|
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -846,38 +973,43 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
browser.navigateUp();
|
browser.navigateUp();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ColoredBox(
|
child: SelectionBarOverlay(
|
||||||
color: colors.bg,
|
bar: selectionAtBottom
|
||||||
child: RefreshIndicator(
|
? _buildSelectionBar(context, selectedItems)
|
||||||
color: colors.accent,
|
: null,
|
||||||
backgroundColor: colors.surface,
|
child: ColoredBox(
|
||||||
onRefresh: () {
|
color: colors.bg,
|
||||||
unawaited(sync.syncOnPull());
|
child: RefreshIndicator(
|
||||||
return browser.reload();
|
color: colors.accent,
|
||||||
},
|
backgroundColor: colors.surface,
|
||||||
// `topBarSliver`'s floating header can collapse all the way to
|
onRefresh: () {
|
||||||
// zero height (fully scrolled away), at which point the sticky
|
unawaited(sync.syncOnPull());
|
||||||
// controls row right below it in `contentSlivers` would otherwise
|
return browser.reload();
|
||||||
// ride up underneath the status bar instead of stopping below it
|
},
|
||||||
// - the floating top bar used to be the only thing reserving that
|
// `topBarSliver`'s floating header can collapse all the way to
|
||||||
// space (via its own internal `SafeArea`), and that reservation
|
// zero height (fully scrolled away), at which point the sticky
|
||||||
// disappears along with it once it's fully hidden. Wrapping the
|
// controls row right below it in `contentSlivers` would otherwise
|
||||||
// whole scroll view keeps the inset outside the scrolling region
|
// ride up underneath the status bar instead of stopping below it
|
||||||
// entirely, so it's never implicated in the floating header's own
|
// - the floating top bar used to be the only thing reserving that
|
||||||
// collapse/reveal math - safe to apply unconditionally, since
|
// space (via its own internal `SafeArea`), and that reservation
|
||||||
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
|
// disappears along with it once it's fully hidden. Wrapping the
|
||||||
// status bar there).
|
// whole scroll view keeps the inset outside the scrolling region
|
||||||
child: SafeArea(
|
// entirely, so it's never implicated in the floating header's own
|
||||||
top: true,
|
// collapse/reveal math - safe to apply unconditionally, since
|
||||||
bottom: false,
|
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
|
||||||
child: CustomScrollView(
|
// status bar there).
|
||||||
controller: widget.scrollController,
|
child: SafeArea(
|
||||||
// Pull-to-refresh needs a scroll physics that allows dragging
|
top: true,
|
||||||
// past the edge even when content doesn't fill the viewport -
|
bottom: false,
|
||||||
// an empty or single-item list otherwise can't be pulled at
|
child: CustomScrollView(
|
||||||
// all under the platform default physics.
|
controller: widget.scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
// Pull-to-refresh needs a scroll physics that allows dragging
|
||||||
slivers: contentSlivers,
|
// past the edge even when content doesn't fill the viewport -
|
||||||
|
// an empty or single-item list otherwise can't be pulled at
|
||||||
|
// all under the platform default physics.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import '../providers/settings_controller.dart';
|
|||||||
import '../services/download_service.dart';
|
import '../services/download_service.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import '../widgets/details/details_sheet.dart';
|
import '../widgets/details/details_sheet.dart';
|
||||||
|
import '../widgets/filter_mode_rows.dart';
|
||||||
import '../widgets/noo/core/noo_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/noo/core/noo_chip.dart';
|
import '../widgets/noo/core/noo_chip.dart';
|
||||||
import '../widgets/noo/core/noo_segmented_control.dart';
|
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||||
import '../widgets/noo/core/noo_toggle.dart';
|
|
||||||
import '../widgets/noo/files/noo_file_kind.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
import '../widgets/noo/lists/noo_grouped_list.dart';
|
import '../widgets/noo/lists/noo_grouped_list.dart';
|
||||||
import '../widgets/noo/lists/noo_selection_bar.dart';
|
import '../widgets/noo/lists/noo_selection_bar.dart';
|
||||||
@@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
|||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/selection_bar_overlay.dart';
|
||||||
import '../widgets/tabs/tab_state_slivers.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';
|
||||||
@@ -57,8 +58,14 @@ class PhotosView extends StatefulWidget {
|
|||||||
/// This tab's own shell top bar, planted as its first sliver - see
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
final PreferredSizeWidget? topBar;
|
final PreferredSizeWidget? topBar;
|
||||||
|
final ValueChanged<bool>? onSelectionChanged;
|
||||||
|
|
||||||
const PhotosView({super.key, required this.scrollController, this.topBar});
|
const PhotosView({
|
||||||
|
super.key,
|
||||||
|
required this.scrollController,
|
||||||
|
this.topBar,
|
||||||
|
this.onSelectionChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PhotosView> createState() => _PhotosViewState();
|
State<PhotosView> createState() => _PhotosViewState();
|
||||||
@@ -81,10 +88,12 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
if (!_selectedIds.remove(item.id)) _selectedIds.add(item.id);
|
||||||
});
|
});
|
||||||
|
widget.onSelectionChanged?.call(_isSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
setState(_selectedIds.clear);
|
setState(_selectedIds.clear);
|
||||||
|
widget.onSelectionChanged?.call(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
/// Mirrors `FilesView._handlePickTap` - Photos has no folders, so this is
|
||||||
@@ -214,6 +223,10 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final selectionAtBottom =
|
||||||
|
_isSelecting &&
|
||||||
|
context.watch<SettingsController>().selectionBarPosition ==
|
||||||
|
SelectionBarPosition.bottom;
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final isDesktop = NooLayout.isDesktop(context);
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
final photosController = context.watch<PhotosController>();
|
final photosController = context.watch<PhotosController>();
|
||||||
@@ -247,23 +260,25 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
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 same slot instead.
|
// the same slot instead.
|
||||||
SliverPersistentHeader(
|
if (!selectionAtBottom)
|
||||||
pinned: !_isSelecting,
|
SliverPersistentHeader(
|
||||||
delegate: StickyHeaderDelegate(
|
pinned: true,
|
||||||
height: _isSelecting ? 56 : 64,
|
delegate: StickyHeaderDelegate(
|
||||||
child: _isSelecting
|
floating: _isSelecting,
|
||||||
? _buildSelectionBar(context, pick, selectedItems)
|
height: _isSelecting ? 56 : 64,
|
||||||
: Padding(
|
child: _isSelecting
|
||||||
padding: const EdgeInsets.fromLTRB(
|
? _buildSelectionBar(context, pick, selectedItems)
|
||||||
NooSpace.sm,
|
: Padding(
|
||||||
NooSpace.sm,
|
padding: const EdgeInsets.fromLTRB(
|
||||||
NooSpace.sm,
|
NooSpace.sm,
|
||||||
NooSpace.xs,
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xs,
|
||||||
|
),
|
||||||
|
child: controlsRow,
|
||||||
),
|
),
|
||||||
child: controlsRow,
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
if (photosController.isLoading && allPhotos.isEmpty)
|
if (photosController.isLoading && allPhotos.isEmpty)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
@@ -359,7 +374,13 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// Clearance so the last row isn't hidden behind the nav bar,
|
// Clearance so the last row isn't hidden behind the nav bar,
|
||||||
// regardless of grid length - see `bottomBarClearance`'s own doc
|
// regardless of grid length - see `bottomBarClearance`'s own doc
|
||||||
// comment for why this has to be dynamic rather than a flat 100.
|
// comment for why this has to be dynamic rather than a flat 100.
|
||||||
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height:
|
||||||
|
bottomBarClearance(context) +
|
||||||
|
(selectionAtBottom ? SelectionBarOverlay.clearance : 0),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -367,24 +388,29 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSelecting) _clearSelection();
|
if (!didPop && _isSelecting) _clearSelection();
|
||||||
},
|
},
|
||||||
child: ColoredBox(
|
child: SelectionBarOverlay(
|
||||||
color: colors.bg,
|
bar: selectionAtBottom
|
||||||
child: RefreshIndicator(
|
? _buildSelectionBar(context, pick, selectedItems)
|
||||||
color: colors.accent,
|
: null,
|
||||||
backgroundColor: colors.surface,
|
child: ColoredBox(
|
||||||
onRefresh: photosController.fetchAllMedia,
|
color: colors.bg,
|
||||||
// See files_view.dart's identical fix - without this, the sticky
|
child: RefreshIndicator(
|
||||||
// controls row rides up under the status bar once the floating
|
color: colors.accent,
|
||||||
// top bar above it fully collapses.
|
backgroundColor: colors.surface,
|
||||||
child: SafeArea(
|
onRefresh: photosController.fetchAllMedia,
|
||||||
top: true,
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
bottom: false,
|
// controls row rides up under the status bar once the floating
|
||||||
child: CustomScrollView(
|
// top bar above it fully collapses.
|
||||||
controller: widget.scrollController,
|
child: SafeArea(
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
top: true,
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
bottom: false,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
child: CustomScrollView(
|
||||||
slivers: contentSlivers,
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -404,7 +430,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
final filtersActive =
|
final filtersActive =
|
||||||
_typeFilter != _PhotoTypeFilter.all ||
|
_typeFilter != _PhotoTypeFilter.all ||
|
||||||
photos.showFavoritesOnly ||
|
photos.showFavoritesOnly ||
|
||||||
photos.showHidden ||
|
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||||
files.storageScope != StorageScope.cloud;
|
files.storageScope != StorageScope.cloud;
|
||||||
|
|
||||||
// A plain Row, not a horizontally-scrolling one - see
|
// A plain Row, not a horizontally-scrolling one - see
|
||||||
@@ -521,63 +547,74 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Files' own type filter - not a fully-labelled track, so
|
// Files' own type filter - not a fully-labelled track, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<_PhotoTypeFilter>(
|
FilterSection(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||||
value: _typeFilter,
|
fill: true,
|
||||||
onChanged: (filter) => setState(() {
|
onSurface: true,
|
||||||
_typeFilter = filter;
|
labelOnlySelected: true,
|
||||||
setSheetState(() {});
|
value: _typeFilter,
|
||||||
}),
|
onChanged: (filter) => setState(() {
|
||||||
options: const [
|
_typeFilter = filter;
|
||||||
NooSegmentOption(
|
setSheetState(() {});
|
||||||
value: _PhotoTypeFilter.all,
|
}),
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: _PhotoTypeFilter.image,
|
label: 'All',
|
||||||
icon: LucideIcons.image,
|
),
|
||||||
label: 'Photos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.image,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.image,
|
||||||
value: _PhotoTypeFilter.video,
|
label: 'Photos',
|
||||||
icon: LucideIcons.film,
|
),
|
||||||
label: 'Videos',
|
NooSegmentOption(
|
||||||
),
|
value: _PhotoTypeFilter.video,
|
||||||
],
|
icon: LucideIcons.film,
|
||||||
|
label: 'Videos',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
FilterSection(
|
||||||
children: [
|
icon: LucideIcons.heart,
|
||||||
NooSettingsRow(
|
title: 'Favorites',
|
||||||
icon: LucideIcons.heart,
|
child: NooSegmentedControl<bool>(
|
||||||
label: const Text('Favorites only'),
|
fill: true,
|
||||||
trailing: NooToggle(
|
onSurface: true,
|
||||||
checked: photos.showFavoritesOnly,
|
labelOnlySelected: true,
|
||||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
value: photos.showFavoritesOnly,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != photos.showFavoritesOnly) {
|
||||||
|
photos.toggleFavoritesFilter();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: false,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
),
|
),
|
||||||
),
|
NooSegmentOption(
|
||||||
NooSettingsRow(
|
value: true,
|
||||||
icon: LucideIcons.eye,
|
icon: LucideIcons.heart,
|
||||||
label: const Text('Show hidden files'),
|
label: 'Favorites only',
|
||||||
trailing: NooToggle(
|
|
||||||
checked: photos.showHidden,
|
|
||||||
onChanged: (_) => photos.toggleShowHidden(),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
NooSettingsRow(
|
),
|
||||||
icon: LucideIcons.hardDrive,
|
),
|
||||||
label: const Text('External storage'),
|
const SizedBox(height: 22),
|
||||||
trailing: NooToggle(
|
HiddenFilesFilterRow(
|
||||||
checked: files.storageScope == StorageScope.external,
|
value: photos.hiddenFilter,
|
||||||
onChanged: (external) => files.setStorageScope(
|
onChanged: photos.setHiddenFilter,
|
||||||
external ? StorageScope.external : StorageScope.cloud,
|
),
|
||||||
),
|
const SizedBox(height: 22),
|
||||||
),
|
StorageScopeRow(
|
||||||
),
|
value: files.storageScope,
|
||||||
],
|
onChanged: files.setStorageScope,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Widget buildAppTabView(
|
|||||||
AppTab tab,
|
AppTab tab,
|
||||||
ScrollController controller, {
|
ScrollController controller, {
|
||||||
PreferredSizeWidget? topBar,
|
PreferredSizeWidget? topBar,
|
||||||
|
ValueChanged<bool>? onSelectionChanged,
|
||||||
}) {
|
}) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case AppTab.files:
|
case AppTab.files:
|
||||||
@@ -33,11 +34,20 @@ Widget buildAppTabView(
|
|||||||
key: const ValueKey('files'),
|
key: const ValueKey('files'),
|
||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
topBar: topBar,
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
);
|
);
|
||||||
case AppTab.photos:
|
case AppTab.photos:
|
||||||
return PhotosView(scrollController: controller, topBar: topBar);
|
return PhotosView(
|
||||||
|
scrollController: controller,
|
||||||
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
|
);
|
||||||
case AppTab.favorites:
|
case AppTab.favorites:
|
||||||
return FavoritesView(scrollController: controller, topBar: topBar);
|
return FavoritesView(
|
||||||
|
scrollController: controller,
|
||||||
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
|
);
|
||||||
case AppTab.activity:
|
case AppTab.activity:
|
||||||
return ActivityView(scrollController: controller, topBar: topBar);
|
return ActivityView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.trash:
|
case AppTab.trash:
|
||||||
@@ -52,6 +62,7 @@ Widget buildAppTabView(
|
|||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
offline: true,
|
offline: true,
|
||||||
topBar: topBar,
|
topBar: topBar,
|
||||||
|
onSelectionChanged: onSelectionChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
import 'avatar_menu.dart';
|
import 'avatar_menu.dart';
|
||||||
import 'create_menu.dart';
|
import 'create_menu.dart';
|
||||||
import 'noo/nav/noo_top_bar.dart';
|
import 'noo/nav/noo_top_bar.dart';
|
||||||
|
import 'noo/core/noo_button.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
|
/// The shell's mobile top bar (DESIGN_SYSTEM.md 3, "Mobile"): every regular
|
||||||
@@ -11,9 +14,9 @@ import 'shell/shell_common.dart';
|
|||||||
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
|
/// tabs built their own via `SyncedHeaderScaffold`, which the rework drops).
|
||||||
/// Built on [NooTopBar]:
|
/// Built on [NooTopBar]:
|
||||||
///
|
///
|
||||||
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
|
/// - iOS: large title, Upload button and account avatar in one row, then
|
||||||
/// create/upload flow) and the account avatar trailing, then the large
|
/// an inline search field that opens [SearchView]. Upload shares Android's
|
||||||
/// title, then an inline search field that opens [SearchView].
|
/// Auto/Collapsed/Expanded preference.
|
||||||
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
||||||
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
||||||
/// isn't in the bottom bar, an inline search-field-styled launcher
|
/// isn't in the bottom bar, an inline search-field-styled launcher
|
||||||
@@ -37,6 +40,8 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
final AppTab tab;
|
final AppTab tab;
|
||||||
final bool searchInBottomBar;
|
final bool searchInBottomBar;
|
||||||
final NooNavMenuStyle navMenuStyle;
|
final NooNavMenuStyle navMenuStyle;
|
||||||
|
final AvatarPosition avatarPosition;
|
||||||
|
final FabStyle uploadButtonStyle;
|
||||||
|
|
||||||
const AppTopBar({
|
const AppTopBar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -44,15 +49,23 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
required this.tab,
|
required this.tab,
|
||||||
this.searchInBottomBar = false,
|
this.searchInBottomBar = false,
|
||||||
this.navMenuStyle = NooNavMenuStyle.drawer,
|
this.navMenuStyle = NooNavMenuStyle.drawer,
|
||||||
|
this.avatarPosition = AvatarPosition.top,
|
||||||
|
this.uploadButtonStyle = FabStyle.auto,
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get _ios => style == NooNavStyle.ios;
|
bool get _ios => style == NooNavStyle.ios;
|
||||||
bool get _showSearch => !searchInBottomBar;
|
bool get _showSearch => !searchInBottomBar;
|
||||||
|
bool get _uploadCollapsed => switch (uploadButtonStyle) {
|
||||||
|
FabStyle.auto => tab != AppTab.files && tab != AppTab.photos,
|
||||||
|
FabStyle.mini => true,
|
||||||
|
FabStyle.expanded => false,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => NooTopBar(
|
Size get preferredSize => NooTopBar(
|
||||||
style: style,
|
style: style,
|
||||||
title: tab.label,
|
title: tab.label,
|
||||||
|
iosActionsInTitleRow: true,
|
||||||
// A throwaway placeholder purely to make preferredSize's "is there a
|
// A throwaway placeholder purely to make preferredSize's "is there a
|
||||||
// search slot" check match what build() actually shows - never
|
// search slot" check match what build() actually shows - never
|
||||||
// inserted into the tree, so its content doesn't matter.
|
// inserted into the tree, so its content doesn't matter.
|
||||||
@@ -64,6 +77,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
return NooTopBar(
|
return NooTopBar(
|
||||||
style: style,
|
style: style,
|
||||||
title: tab.label,
|
title: tab.label,
|
||||||
|
iosActionsInTitleRow: true,
|
||||||
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
||||||
? () => Scaffold.of(context).openDrawer()
|
? () => Scaffold.of(context).openDrawer()
|
||||||
: null,
|
: null,
|
||||||
@@ -74,23 +88,69 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
? const ShellSearchLauncher()
|
? const ShellSearchLauncher()
|
||||||
: null,
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
if (_ios && tab == AppTab.files)
|
if (_ios)
|
||||||
NooTopBarButton(
|
Tooltip(
|
||||||
icon: LucideIcons.plus,
|
message: 'Upload',
|
||||||
tooltip: 'Create',
|
child: Semantics(
|
||||||
onTap: () => showCreateMenu(context),
|
button: true,
|
||||||
|
label: 'Upload',
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
Widget button(double progress) => NooButton(
|
||||||
|
variant: NooButtonVariant.tonal,
|
||||||
|
size: NooButtonSize.compact,
|
||||||
|
icon: LucideIcons.plus,
|
||||||
|
expansion: progress,
|
||||||
|
onTap: () => showCreateMenu(context),
|
||||||
|
child: const Text('Upload'),
|
||||||
|
);
|
||||||
|
final scope = context
|
||||||
|
.dependOnInheritedWidgetOfExactType<
|
||||||
|
UploadButtonAnimation
|
||||||
|
>();
|
||||||
|
if (scope != null) return button(scope.progress);
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(end: _uploadCollapsed ? 0 : 1),
|
||||||
|
duration: MediaQuery.disableAnimationsOf(context)
|
||||||
|
? Duration.zero
|
||||||
|
: NooMotion.base,
|
||||||
|
curve: NooMotion.ease,
|
||||||
|
builder: (context, progress, _) => button(progress),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (navMenuStyle != NooNavMenuStyle.avatarMenu ||
|
||||||
|
avatarPosition == AvatarPosition.top)
|
||||||
|
AvatarNavigationAnchor(
|
||||||
|
child: Builder(
|
||||||
|
builder: (anchorContext) => ShellAvatarButton(
|
||||||
|
hitBox: _ios ? 44 : 48,
|
||||||
|
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||||
|
? () => showAvatarMenu(anchorContext)
|
||||||
|
: null,
|
||||||
|
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||||
|
? 'Menu'
|
||||||
|
: 'Accounts',
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ShellAvatarButton(
|
|
||||||
hitBox: _ios ? 44 : 48,
|
|
||||||
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
|
||||||
? () => showAvatarMenu(context)
|
|
||||||
: null,
|
|
||||||
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
|
||||||
? 'Menu'
|
|
||||||
: 'Accounts',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shares one transition across the shell's separately mounted tab headers.
|
||||||
|
class UploadButtonAnimation extends InheritedWidget {
|
||||||
|
final double progress;
|
||||||
|
const UploadButtonAnimation({
|
||||||
|
super.key,
|
||||||
|
required this.progress,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(UploadButtonAnimation oldWidget) =>
|
||||||
|
progress != oldWidget.progress;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter, lerpDouble;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -6,53 +7,85 @@ import '../providers/session_controller.dart';
|
|||||||
import '../providers/settings_controller.dart';
|
import '../providers/settings_controller.dart';
|
||||||
import '../providers/trash_controller.dart';
|
import '../providers/trash_controller.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'noo/core/noo_avatar.dart';
|
|
||||||
import '../views/login_view.dart';
|
import '../views/login_view.dart';
|
||||||
|
import 'noo/core/noo_avatar.dart';
|
||||||
import 'noo/core/noo_badge.dart';
|
import 'noo/core/noo_badge.dart';
|
||||||
|
import 'noo/nav/noo_bottom_bar.dart';
|
||||||
|
import 'noo/noo_layout.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
/// The dropdown [ShellAvatarButton] opens when
|
/// The shell supplies the expanding-card presentation. Standalone top bars
|
||||||
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] -
|
/// retain a dialog fallback (for screens outside the shell).
|
||||||
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs,
|
Future<void> showAvatarMenu(BuildContext context) async {
|
||||||
/// Settings), reached from the avatar instead of a hamburger-triggered
|
final scope = AvatarNavigationScope.maybeOf(context);
|
||||||
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/
|
if (scope != null) {
|
||||||
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming)
|
scope._host.openFrom(context);
|
||||||
/// barrier and a card anchored under the top bar rather than a modal
|
return;
|
||||||
/// sheet/dialog, and there's no existing anchored-popup primitive in this
|
}
|
||||||
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
|
await showGeneralDialog<void>(
|
||||||
/// content column the way this needs to).
|
|
||||||
Future<void> showAvatarMenu(BuildContext context) {
|
|
||||||
return showGeneralDialog<void>(
|
|
||||||
context: context,
|
context: context,
|
||||||
barrierColor: Colors.transparent,
|
barrierColor: Colors.transparent,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
barrierLabel: 'Close menu',
|
barrierLabel: 'Close menu',
|
||||||
transitionDuration: NooMotion.fast,
|
transitionDuration: NooMotion.base,
|
||||||
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
|
pageBuilder: (context, _, _) => Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
||||||
|
child: _StandaloneAvatarCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
||||||
opacity: animation,
|
opacity: animation,
|
||||||
child: ScaleTransition(
|
child: ScaleTransition(
|
||||||
alignment: Alignment.topRight,
|
alignment: Alignment.topRight,
|
||||||
scale: Tween<double>(
|
scale: Tween(begin: .88, end: 1.0).animate(
|
||||||
begin: 0.96,
|
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
|
||||||
end: 1,
|
),
|
||||||
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
|
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarMenuContent extends StatefulWidget {
|
class _StandaloneAvatarCard extends StatefulWidget {
|
||||||
const _AvatarMenuContent();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
|
State<_StandaloneAvatarCard> createState() => _StandaloneAvatarCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
class _StandaloneAvatarCardState extends State<_StandaloneAvatarCard> {
|
||||||
bool _expanded = false;
|
bool expanded = false;
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => AvatarMenuCard(
|
||||||
|
expanded: expanded,
|
||||||
|
onExpand: () => setState(() => expanded = !expanded),
|
||||||
|
onClose: () => Navigator.pop(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account controls and hidden-tab navigation, shared by both card positions.
|
||||||
|
class AvatarMenuCard extends StatelessWidget {
|
||||||
|
final bool bottom;
|
||||||
|
final bool expanded;
|
||||||
|
final bool movingAvatar;
|
||||||
|
final bool showHeaderAvatar;
|
||||||
|
final Key? headerAvatarKey;
|
||||||
|
final VoidCallback onExpand;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
const AvatarMenuCard({
|
||||||
|
super.key,
|
||||||
|
this.bottom = false,
|
||||||
|
this.movingAvatar = false,
|
||||||
|
this.showHeaderAvatar = true,
|
||||||
|
this.headerAvatarKey,
|
||||||
|
required this.expanded,
|
||||||
|
required this.onExpand,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -60,224 +93,310 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
final trashCount = context.watch<TrashController>().items.length;
|
final trashCount = context.watch<TrashController>().items.length;
|
||||||
final hiddenTabs = settings.tabOrder
|
void settingsTap() {
|
||||||
.where((t) => settings.hiddenTabs.contains(t))
|
onClose();
|
||||||
.toList();
|
|
||||||
|
|
||||||
void closeAndOpenSettings() {
|
|
||||||
Navigator.pop(context);
|
|
||||||
openSettings(context);
|
openSettings(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Align(
|
final header = InkWell(
|
||||||
alignment: Alignment.topCenter,
|
onTap: onExpand,
|
||||||
// Just the status-bar inset, not the top bar's own height on top of
|
child: Padding(
|
||||||
// it - the card covers the top bar (title included) rather than
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
// sitting below it, so opening it reads as the avatar growing into
|
child: Row(
|
||||||
// this instead of a separate element appearing underneath the row
|
children: [
|
||||||
// it came from.
|
Expanded(
|
||||||
child: SafeArea(
|
child: Column(
|
||||||
bottom: false,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md),
|
Text(
|
||||||
child: SizedBox(
|
session.username,
|
||||||
width: double.infinity,
|
maxLines: 1,
|
||||||
child: Container(
|
overflow: TextOverflow.ellipsis,
|
||||||
// No `color`/`clipBehavior` here - a `Container` with both a
|
style: NooText.bodyL.copyWith(
|
||||||
// `border` and `clipBehavior` set paints the border as part
|
fontWeight: FontWeight.w600,
|
||||||
// of its *outer* decoration, then paints its (clipped) child
|
color: colors.fg1,
|
||||||
// on top right up to that same boundary with no gap for the
|
|
||||||
// border's own stroke width to show through. An opaque child
|
|
||||||
// touching that edge - every row below has its own full-bleed
|
|
||||||
// `Material` fill - then paints straight over the inner half
|
|
||||||
// of the border, which read as the border going missing
|
|
||||||
// specifically wherever an opaque row sits (every corner but
|
|
||||||
// the two by the header, which has no opaque fill of its
|
|
||||||
// own). The 1px `Padding` + inset `ClipRRect` below keeps the
|
|
||||||
// clipped, filled content entirely inside the border's own
|
|
||||||
// stroke instead of racing it for the same pixels.
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: colors.line),
|
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
|
||||||
// `nooDialogShadow` alone is a wide, soft, fairly faint
|
|
||||||
// shadow - built for a desktop dialog with plenty of room
|
|
||||||
// to fall off into. On a small card over a dark theme's
|
|
||||||
// near-black `bg`, that falloff is too gradual to read as
|
|
||||||
// elevation at all (a dark shadow needs real density close
|
|
||||||
// to the edge to be visible against an already-dark
|
|
||||||
// backdrop). A second, tighter, more opaque contact shadow
|
|
||||||
// underneath it gives an immediate value-step right at the
|
|
||||||
// card's edge in both themes, with the soft one still
|
|
||||||
// doing the wider ambient falloff on top.
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Color(0x40000000),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
nooDialogShadow,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(1),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card - 1),
|
|
||||||
child: Material(
|
|
||||||
color: colors.surface,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Avatar on the right, name/host on the left - mirrors the
|
|
||||||
// top bar's own right-aligned avatar exactly, so this
|
|
||||||
// header reads as a continuation of the button that
|
|
||||||
// opened it rather than a disconnected card.
|
|
||||||
InkWell(
|
|
||||||
onTap: () => setState(() => _expanded = !_expanded),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(NooSpace.md),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
session.username,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: NooText.bodyL.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: colors.fg1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
serverHost(session.serverUrl),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: NooText.meta.copyWith(
|
|
||||||
color: colors.fg3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
NooAvatar(
|
|
||||||
initials: accountInitial(session.username),
|
|
||||||
current: true,
|
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Container(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: colors.fg1.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_expanded
|
|
||||||
? LucideIcons.chevronUp
|
|
||||||
: LucideIcons.chevronDown,
|
|
||||||
size: 20,
|
|
||||||
color: colors.fg2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AnimatedSize(
|
|
||||||
duration: NooMotion.fast,
|
|
||||||
curve: NooMotion.ease,
|
|
||||||
alignment: Alignment.topCenter,
|
|
||||||
child: _expanded
|
|
||||||
? Column(
|
|
||||||
children: [
|
|
||||||
for (final account in session.accounts)
|
|
||||||
if (account.id != session.activeAccountId)
|
|
||||||
_OtherAccountRow(
|
|
||||||
name: account.username,
|
|
||||||
host: serverHost(account.serverUrl),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
session.switchAccount(account.id);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(
|
|
||||||
NooSpace.md,
|
|
||||||
0,
|
|
||||||
NooSpace.md,
|
|
||||||
NooSpace.md,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _AccountButton(
|
|
||||||
icon: LucideIcons.userPlus,
|
|
||||||
label: 'Add Account',
|
|
||||||
onTap: () {
|
|
||||||
final nav = Navigator.of(
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
nav.pop();
|
|
||||||
nav.push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) =>
|
|
||||||
const LoginView(
|
|
||||||
isAddingAccount: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _AccountButton(
|
|
||||||
icon: LucideIcons.users,
|
|
||||||
label: 'Manage Accounts',
|
|
||||||
onTap: closeAndOpenSettings,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: const SizedBox(width: double.infinity),
|
|
||||||
),
|
|
||||||
if (hiddenTabs.isNotEmpty) ...[
|
|
||||||
Divider(height: 1, color: colors.line),
|
|
||||||
for (final tab in hiddenTabs)
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: tab.icon,
|
|
||||||
label: Text(tab.label),
|
|
||||||
trailing: tab == AppTab.trash && trashCount > 0
|
|
||||||
? NooBadge(
|
|
||||||
tone: NooBadgeTone.accent,
|
|
||||||
child: Text('$trashCount'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
settings.requestTab(tab);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Divider(height: 1, color: colors.line),
|
|
||||||
NooSettingsRow(
|
|
||||||
icon: LucideIcons.settings,
|
|
||||||
label: const Text('Settings'),
|
|
||||||
onTap: closeAndOpenSettings,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
serverHost(session.serverUrl),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||||
|
size: 20,
|
||||||
|
color: colors.fg2,
|
||||||
|
),
|
||||||
|
if (showHeaderAvatar) const SizedBox(width: 12),
|
||||||
|
if (showHeaderAvatar)
|
||||||
|
movingAvatar
|
||||||
|
? SizedBox.square(key: headerAvatarKey, dimension: 32)
|
||||||
|
: GestureDetector(
|
||||||
|
onTap: onClose,
|
||||||
|
child: NooAvatar(
|
||||||
|
key: headerAvatarKey,
|
||||||
|
initials: accountInitial(session.username),
|
||||||
|
current: true,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final accountSection = AnimatedSize(
|
||||||
|
duration: NooMotion.base,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: bottom ? Alignment.bottomCenter : Alignment.topCenter,
|
||||||
|
child: expanded
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
for (final account in session.accounts)
|
||||||
|
if (account.id != session.activeAccountId)
|
||||||
|
_OtherAccountRow(
|
||||||
|
name: account.username,
|
||||||
|
host: serverHost(account.serverUrl),
|
||||||
|
onTap: () {
|
||||||
|
onClose();
|
||||||
|
session.switchAccount(account.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _AccountButton(
|
||||||
|
icon: LucideIcons.userPlus,
|
||||||
|
label: 'Add Account',
|
||||||
|
onTap: () {
|
||||||
|
final nav = Navigator.of(context);
|
||||||
|
onClose();
|
||||||
|
nav.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const LoginView(isAddingAccount: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _AccountButton(
|
||||||
|
icon: LucideIcons.users,
|
||||||
|
label: 'Manage Accounts',
|
||||||
|
onTap: settingsTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
);
|
||||||
|
final rows = Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!bottom) accountSection,
|
||||||
|
for (final tab in settings.tabOrder.where(settings.hiddenTabs.contains))
|
||||||
|
NooSettingsRow(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
icon: tab.icon,
|
||||||
|
label: Text(tab.label),
|
||||||
|
trailing: tab == AppTab.trash && trashCount > 0
|
||||||
|
? NooBadge(
|
||||||
|
tone: NooBadgeTone.accent,
|
||||||
|
child: Text('$trashCount'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
onClose();
|
||||||
|
settings.requestTab(tab);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Divider(height: 1, color: colors.line),
|
||||||
|
NooSettingsRow(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
icon: LucideIcons.settings,
|
||||||
|
label: const Text('Settings'),
|
||||||
|
onTap: settingsTap,
|
||||||
|
),
|
||||||
|
if (bottom) ...[header, accountSection],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final content = SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(1),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card - 1),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (!bottom) header,
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: SingleChildScrollView(child: rows),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return movingAvatar
|
||||||
|
? content
|
||||||
|
: _AvatarSurface(
|
||||||
|
radius: BorderRadius.circular(NooRadii.card),
|
||||||
|
child: content,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarSurface extends StatelessWidget {
|
||||||
|
final BorderRadius radius;
|
||||||
|
final Widget child;
|
||||||
|
final bool connected;
|
||||||
|
const _AvatarSurface({
|
||||||
|
required this.radius,
|
||||||
|
required this.child,
|
||||||
|
this.connected = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final settings = context.watch<SettingsController>();
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final frosted = settings.bottomBarFrosted;
|
||||||
|
final surface = DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: frosted
|
||||||
|
? colors.surface.withValues(alpha: settings.bottomBarFrostedOpacity)
|
||||||
|
: colors.surface,
|
||||||
|
border: connected ? null : Border.all(color: colors.line),
|
||||||
|
borderRadius: radius,
|
||||||
|
),
|
||||||
|
child: Padding(padding: const EdgeInsets.all(1), child: child),
|
||||||
|
);
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: radius,
|
||||||
|
boxShadow: frosted || connected ? null : const [nooDialogShadow],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: frosted
|
||||||
|
? BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: settings.bottomBarFrostedBlur,
|
||||||
|
sigmaY: settings.bottomBarFrostedBlur,
|
||||||
|
),
|
||||||
|
child: surface,
|
||||||
|
)
|
||||||
|
: surface,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AvatarNavigationScope extends InheritedNotifier<AnimationController> {
|
||||||
|
final _AvatarNavigationHostState _host;
|
||||||
|
AvatarNavigationScope._({
|
||||||
|
required _AvatarNavigationHostState host,
|
||||||
|
required super.child,
|
||||||
|
}) : _host = host,
|
||||||
|
super(notifier: host.animation);
|
||||||
|
static AvatarNavigationScope? maybeOf(BuildContext context) =>
|
||||||
|
context.dependOnInheritedWidgetOfExactType<AvatarNavigationScope>();
|
||||||
|
double get progress =>
|
||||||
|
Curves.easeInOutCubicEmphasized.transform(_host.animation.value);
|
||||||
|
bool get bottom => _host.bottom;
|
||||||
|
void close() => _host.close();
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(AvatarNavigationScope oldWidget) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the top avatar's layout slot while the single animated avatar moves
|
||||||
|
/// into the card. Its own context gives us the real screen-space origin.
|
||||||
|
class AvatarNavigationAnchor extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
const AvatarNavigationAnchor({super.key, required this.child});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scope = AvatarNavigationScope.maybeOf(context);
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: scope != null && scope.progress > 0,
|
||||||
|
child: ClipRect(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
widthFactor: 1 - (scope?.progress ?? 0),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: scope != null && scope.progress > 0 ? 0 : 1,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the content pane moves; the bottom navigation stays anchored.
|
||||||
|
class AvatarNavigationBody extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
const AvatarNavigationBody({super.key, required this.child});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scope = AvatarNavigationScope.maybeOf(context);
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: scope == null || scope.bottom
|
||||||
|
? 0
|
||||||
|
: (scope._host.cardHeight + 12) * scope.progress,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AvatarNavigationSatellite extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
const AvatarNavigationSatellite({super.key, required this.size});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scope = AvatarNavigationScope.maybeOf(context);
|
||||||
|
if (context.watch<SettingsController>().bottomBarStyle ==
|
||||||
|
NooBottomBarStyle.attached) {
|
||||||
|
return Builder(
|
||||||
|
builder: (anchorContext) => ShellAvatarButton(
|
||||||
|
hitBox: size,
|
||||||
|
label: 'Menu',
|
||||||
|
onTap: () => showAvatarMenu(anchorContext),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: scope != null && scope.progress > 0,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: scope != null && scope.progress > 0 ? 0 : 1,
|
||||||
|
child: SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: _AvatarSurface(
|
||||||
|
radius: BorderRadius.circular(size / 2),
|
||||||
|
child: Builder(
|
||||||
|
builder: (anchorContext) => ShellAvatarButton(
|
||||||
|
hitBox: size,
|
||||||
|
label: 'Menu',
|
||||||
|
onTap: () => showAvatarMenu(anchorContext),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -287,6 +406,286 @@ class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class AvatarNavigationHost extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
const AvatarNavigationHost({super.key, required this.child});
|
||||||
|
@override
|
||||||
|
State<AvatarNavigationHost> createState() => _AvatarNavigationHostState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarNavigationHostState extends State<AvatarNavigationHost>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController animation = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 420),
|
||||||
|
reverseDuration: const Duration(milliseconds: 320),
|
||||||
|
);
|
||||||
|
final GlobalKey measureKey = GlobalKey();
|
||||||
|
final GlobalKey measureAvatarKey = GlobalKey();
|
||||||
|
Offset avatarOffset = const Offset(0, 17);
|
||||||
|
Rect? origin;
|
||||||
|
bool bottom = false;
|
||||||
|
bool expanded = false;
|
||||||
|
double cardHeight = 150;
|
||||||
|
bool measuring = false;
|
||||||
|
void measure() {
|
||||||
|
if (measuring) return;
|
||||||
|
measuring = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
measuring = false;
|
||||||
|
if (!mounted) return;
|
||||||
|
final box = measureKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
final avatarBox =
|
||||||
|
measureAvatarKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
final offset = box != null && avatarBox != null
|
||||||
|
? avatarBox.localToGlobal(Offset.zero, ancestor: box)
|
||||||
|
: avatarOffset;
|
||||||
|
if (box != null &&
|
||||||
|
((box.size.height - cardHeight).abs() > .5 ||
|
||||||
|
(offset - avatarOffset).distance > .5)) {
|
||||||
|
setState(() {
|
||||||
|
cardHeight = box.size.height;
|
||||||
|
avatarOffset = offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void openFrom(BuildContext context) {
|
||||||
|
if (animation.value > 0) {
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final settings = context.read<SettingsController>();
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null) return;
|
||||||
|
final hostBox = this.context.findRenderObject() as RenderBox;
|
||||||
|
final center = hostBox.globalToLocal(
|
||||||
|
box.localToGlobal(box.size.center(Offset.zero)),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
bottom = settings.avatarPosition == AvatarPosition.bottom;
|
||||||
|
final diameter = bottom
|
||||||
|
? NooBottomBar.rowHeight(
|
||||||
|
NooLayout.navStyle(context),
|
||||||
|
settings.bottomBarStyle,
|
||||||
|
)
|
||||||
|
: 32.0;
|
||||||
|
origin = Rect.fromCenter(
|
||||||
|
center: center,
|
||||||
|
width: diameter,
|
||||||
|
height: diameter,
|
||||||
|
);
|
||||||
|
expanded = false;
|
||||||
|
});
|
||||||
|
animation.duration = MediaQuery.disableAnimationsOf(context)
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 420);
|
||||||
|
animation.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
void close() {
|
||||||
|
animation.reverseDuration = MediaQuery.disableAnimationsOf(context)
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 320);
|
||||||
|
animation.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
animation.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final settings = context.watch<SettingsController>();
|
||||||
|
final session = context.watch<SessionController>();
|
||||||
|
final enabled = settings.navMenuStyle == NooNavMenuStyle.avatarMenu;
|
||||||
|
return AvatarNavigationScope._(
|
||||||
|
host: this,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, _) {
|
||||||
|
final progress = Curves.easeInOutCubicEmphasized.transform(
|
||||||
|
animation.value,
|
||||||
|
);
|
||||||
|
return PopScope(
|
||||||
|
canPop: animation.value == 0,
|
||||||
|
onPopInvokedWithResult: (didPop, _) {
|
||||||
|
if (!didPop) close();
|
||||||
|
},
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final height = constraints.maxHeight;
|
||||||
|
final safeTop = MediaQuery.viewPaddingOf(context).top;
|
||||||
|
final attached =
|
||||||
|
bottom &&
|
||||||
|
settings.bottomBarStyle == NooBottomBarStyle.attached;
|
||||||
|
final bottomEdge = bottom && origin != null
|
||||||
|
? origin!.center.dy -
|
||||||
|
NooBottomBar.rowHeight(
|
||||||
|
NooLayout.navStyle(context),
|
||||||
|
settings.bottomBarStyle,
|
||||||
|
) /
|
||||||
|
2 -
|
||||||
|
(attached ? 0 : 12)
|
||||||
|
: height;
|
||||||
|
final maxHeight = (bottomEdge - safeTop - 24).clamp(
|
||||||
|
72.0,
|
||||||
|
height * .8,
|
||||||
|
);
|
||||||
|
measure();
|
||||||
|
Widget card({bool measurement = false}) => ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||||
|
child: AvatarMenuCard(
|
||||||
|
bottom: bottom,
|
||||||
|
movingAvatar: measurement || progress < 1,
|
||||||
|
headerAvatarKey: measurement ? measureAvatarKey : null,
|
||||||
|
showHeaderAvatar: !attached,
|
||||||
|
expanded: expanded,
|
||||||
|
onExpand: () => setState(() => expanded = !expanded),
|
||||||
|
onClose: close,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final target = Rect.fromLTWH(
|
||||||
|
attached ? 0 : 12,
|
||||||
|
bottom ? bottomEdge - cardHeight : safeTop + 8,
|
||||||
|
attached ? width : width - 24,
|
||||||
|
cardHeight,
|
||||||
|
);
|
||||||
|
final rect = Rect.lerp(
|
||||||
|
attached
|
||||||
|
? Rect.fromLTWH(0, bottomEdge, width, 0)
|
||||||
|
: origin ?? target,
|
||||||
|
target,
|
||||||
|
progress,
|
||||||
|
)!;
|
||||||
|
final radius = attached
|
||||||
|
? BorderRadius.vertical(
|
||||||
|
top: Radius.circular(NooRadii.card * progress),
|
||||||
|
)
|
||||||
|
: BorderRadius.circular(
|
||||||
|
lerpDouble(
|
||||||
|
(origin?.width ?? 32) / 2,
|
||||||
|
NooRadii.card,
|
||||||
|
progress,
|
||||||
|
)!,
|
||||||
|
);
|
||||||
|
final avatarTarget = Rect.fromLTWH(
|
||||||
|
target.right - 49,
|
||||||
|
target.top + avatarOffset.dy,
|
||||||
|
32,
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
final avatar = Rect.lerp(
|
||||||
|
origin == null
|
||||||
|
? avatarTarget
|
||||||
|
: Rect.fromCenter(
|
||||||
|
center: origin!.center,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
),
|
||||||
|
avatarTarget,
|
||||||
|
progress,
|
||||||
|
)!;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
if (enabled)
|
||||||
|
Positioned(
|
||||||
|
left: attached ? 0 : 12,
|
||||||
|
right: attached ? 0 : 12,
|
||||||
|
top: safeTop,
|
||||||
|
child: Offstage(
|
||||||
|
child:
|
||||||
|
NotificationListener<
|
||||||
|
SizeChangedLayoutNotification
|
||||||
|
>(
|
||||||
|
onNotification: (_) {
|
||||||
|
measure();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: SizeChangedLayoutNotifier(
|
||||||
|
child: KeyedSubtree(
|
||||||
|
key: measureKey,
|
||||||
|
child: card(measurement: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (enabled && progress > 0 && origin != null) ...[
|
||||||
|
Positioned.fill(
|
||||||
|
bottom: bottom ? height - bottomEdge : 0,
|
||||||
|
child: Semantics(
|
||||||
|
label: 'Close menu',
|
||||||
|
button: true,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: close,
|
||||||
|
child: const ColoredBox(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fromRect(
|
||||||
|
rect: rect,
|
||||||
|
child: _AvatarSurface(
|
||||||
|
radius: radius,
|
||||||
|
connected: attached,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: OverflowBox(
|
||||||
|
alignment: bottom
|
||||||
|
? Alignment.bottomRight
|
||||||
|
: Alignment.topRight,
|
||||||
|
minWidth: target.width,
|
||||||
|
maxWidth: target.width,
|
||||||
|
minHeight: target.height,
|
||||||
|
maxHeight: target.height,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: progress < .95,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: ((progress - .3) / .7).clamp(
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
),
|
||||||
|
child: card(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!attached && progress < 1)
|
||||||
|
Positioned.fromRect(
|
||||||
|
rect: avatar,
|
||||||
|
child: Semantics(
|
||||||
|
label: 'Close menu',
|
||||||
|
button: true,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: close,
|
||||||
|
child: NooAvatar(
|
||||||
|
initials: accountInitial(session.username),
|
||||||
|
current: true,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _OtherAccountRow extends StatelessWidget {
|
class _OtherAccountRow extends StatelessWidget {
|
||||||
final String name;
|
final String name;
|
||||||
final String host;
|
final String host;
|
||||||
@@ -331,8 +730,7 @@ class _OtherAccountRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
NooAvatar(initials: accountInitial(name), current: false, size: 32),
|
||||||
const SizedBox(width: 44),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import 'noo/nav/noo_bottom_bar.dart';
|
import 'noo/nav/noo_bottom_bar.dart';
|
||||||
|
import 'avatar_menu.dart';
|
||||||
|
|
||||||
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
|
/// Adapts the app's [AppTab] model onto [NooBottomBar]'s generic
|
||||||
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
|
/// [NooNavDestination] list, so `MainShellView` only deals in tabs and
|
||||||
@@ -16,19 +17,27 @@ import 'noo/nav/noo_bottom_bar.dart';
|
|||||||
class BottomNavBar extends StatelessWidget {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
final VoidCallback? onSearchTap;
|
final VoidCallback? onSearchTap;
|
||||||
|
final bool avatarInBottomBar;
|
||||||
|
|
||||||
const BottomNavBar({
|
const BottomNavBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
this.frosted = false,
|
||||||
|
this.frostedBlur = NooBottomBar.defaultFrostedBlur,
|
||||||
|
this.frostedOpacity = NooBottomBar.defaultFrostedOpacity,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
this.onSearchTap,
|
this.onSearchTap,
|
||||||
|
this.avatarInBottomBar = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,16 +45,35 @@ class BottomNavBar extends StatelessWidget {
|
|||||||
return NooBottomBar(
|
return NooBottomBar(
|
||||||
style: style,
|
style: style,
|
||||||
barStyle: barStyle,
|
barStyle: barStyle,
|
||||||
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final tab in tabs)
|
for (final tab in tabs)
|
||||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||||
],
|
],
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onSelected: onDestinationSelected,
|
onSelected: (index) {
|
||||||
|
AvatarNavigationScope.maybeOf(context)?.close();
|
||||||
|
onDestinationSelected(index);
|
||||||
|
},
|
||||||
searchDestination: onSearchTap == null
|
searchDestination: onSearchTap == null
|
||||||
? null
|
? null
|
||||||
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
|
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
|
||||||
onSearchTap: onSearchTap,
|
onSearchTap: onSearchTap == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
AvatarNavigationScope.maybeOf(context)?.close();
|
||||||
|
onSearchTap!();
|
||||||
|
},
|
||||||
|
avatarSatellite: avatarInBottomBar
|
||||||
|
? AvatarNavigationSatellite(
|
||||||
|
size: NooBottomBar.rowHeight(style, barStyle),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
avatarMenuProgress: avatarInBottomBar
|
||||||
|
? AvatarNavigationScope.maybeOf(context)?.progress ?? 0
|
||||||
|
: 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
|||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
import 'noo/core/noo_chip.dart';
|
import 'noo/core/noo_chip.dart';
|
||||||
import 'noo/core/noo_segmented_control.dart';
|
import 'noo/core/noo_segmented_control.dart';
|
||||||
import 'noo/core/noo_toggle.dart';
|
import 'filter_mode_rows.dart';
|
||||||
import 'noo/lists/noo_grouped_list.dart';
|
import 'noo/lists/noo_grouped_list.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'noo/overlays/noo_sheet.dart';
|
import 'noo/overlays/noo_sheet.dart';
|
||||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final filtersActive =
|
final filtersActive =
|
||||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||||
files.showHiddenFiles ||
|
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@@ -171,54 +171,46 @@ class FilesControlsRow extends StatelessWidget {
|
|||||||
// Same "icon always, label only when selected" pill as
|
// Same "icon always, label only when selected" pill as
|
||||||
// Photos' own type filter below - not a checkmark list, so
|
// Photos' own type filter below - not a checkmark list, so
|
||||||
// the two screens' filter sheets look and behave the same.
|
// the two screens' filter sheets look and behave the same.
|
||||||
NooSegmentedControl<FilesTypeFilter>(
|
FilterSection(
|
||||||
fill: true,
|
icon: LucideIcons.layoutGrid,
|
||||||
onSurface: true,
|
title: 'Type',
|
||||||
labelOnlySelected: true,
|
child: NooSegmentedControl<FilesTypeFilter>(
|
||||||
value: files.filesTypeFilter,
|
fill: true,
|
||||||
onChanged: files.setFilesTypeFilter,
|
onSurface: true,
|
||||||
options: const [
|
labelOnlySelected: true,
|
||||||
NooSegmentOption(
|
value: files.filesTypeFilter,
|
||||||
value: FilesTypeFilter.all,
|
onChanged: files.setFilesTypeFilter,
|
||||||
icon: LucideIcons.layoutGrid,
|
options: const [
|
||||||
label: 'All',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.all,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.layoutGrid,
|
||||||
value: FilesTypeFilter.filesOnly,
|
label: 'All',
|
||||||
icon: LucideIcons.file,
|
),
|
||||||
label: 'Files',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.filesOnly,
|
||||||
NooSegmentOption(
|
icon: LucideIcons.file,
|
||||||
value: FilesTypeFilter.foldersOnly,
|
label: 'Files',
|
||||||
icon: LucideIcons.folder,
|
),
|
||||||
label: 'Folders',
|
NooSegmentOption(
|
||||||
),
|
value: FilesTypeFilter.foldersOnly,
|
||||||
],
|
icon: LucideIcons.folder,
|
||||||
|
label: 'Folders',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooGroupedList(
|
HiddenFilesFilterRow(
|
||||||
children: [
|
value: files.hiddenFilter,
|
||||||
NooSettingsRow(
|
onChanged: files.setHiddenFilter,
|
||||||
icon: LucideIcons.eye,
|
|
||||||
label: const Text('Show hidden files'),
|
|
||||||
trailing: NooToggle(
|
|
||||||
checked: files.showHiddenFiles,
|
|
||||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showStorageScope)
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
if (showStorageScope) ...[
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
StorageScopeRow(
|
||||||
|
value: files.storageScope,
|
||||||
|
onChanged: files.setStorageScope,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../providers/files_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import 'noo/core/noo_segmented_control.dart';
|
||||||
|
|
||||||
|
/// "Hidden files" row of the Files/Photos filter sheets: hide them (the
|
||||||
|
/// default), show only them, or show them alongside everything else. Same
|
||||||
|
/// icon-always/label-when-selected pill as the type filter above it in
|
||||||
|
/// both sheets.
|
||||||
|
class HiddenFilesFilterRow extends StatelessWidget {
|
||||||
|
final HiddenFilesFilter value;
|
||||||
|
final ValueChanged<HiddenFilesFilter> onChanged;
|
||||||
|
|
||||||
|
const HiddenFilesFilterRow({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _FilterModeRow<HiddenFilesFilter>(
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
title: 'Hidden files',
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.hide,
|
||||||
|
icon: LucideIcons.eyeOff,
|
||||||
|
label: 'Hide',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.only,
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
label: 'Only hidden',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: HiddenFilesFilter.include,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "External storage" row of the Files/Photos filter sheets: cloud only
|
||||||
|
/// (the default), only external storage, or both. Files shows the external
|
||||||
|
/// items in a collapsible section for [StorageScope.all].
|
||||||
|
class StorageScopeRow extends StatelessWidget {
|
||||||
|
final StorageScope value;
|
||||||
|
final ValueChanged<StorageScope> onChanged;
|
||||||
|
|
||||||
|
const StorageScopeRow({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _FilterModeRow<StorageScope>(
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
title: 'External storage',
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.cloud,
|
||||||
|
icon: LucideIcons.cloud,
|
||||||
|
label: 'Cloud only',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.external,
|
||||||
|
icon: LucideIcons.hardDrive,
|
||||||
|
label: 'Only external',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: StorageScope.all,
|
||||||
|
icon: LucideIcons.layers,
|
||||||
|
label: 'All',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An icon + title line above [child] - the shape every row of the Files/
|
||||||
|
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||||
|
/// control, which lives in its own file.
|
||||||
|
class FilterSection extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const FilterSection({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: colors.fg2),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FilterModeRow<T> extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final T value;
|
||||||
|
final ValueChanged<T> onChanged;
|
||||||
|
final List<NooSegmentOption<T>> options;
|
||||||
|
|
||||||
|
const _FilterModeRow({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.options,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FilterSection(
|
||||||
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
child: NooSegmentedControl<T>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
labelOnlySelected: true,
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
options: options,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// The blurred/translucent chrome for the media viewer's overlay bars
|
/// Media viewer chrome that shares the navigation frost preference.
|
||||||
/// (`file_viewer_screen.dart`'s top bar, `MediaActionBar`, the video
|
/// Turning frosting off produces an opaque themed surface without blur.
|
||||||
/// transport row) - the one deliberate exception to the design system's
|
|
||||||
/// flat, no-shadow product UI (DESIGN_SYSTEM.md 1.4), since this chrome
|
|
||||||
/// floats over photo/video content rather than over the app's own
|
|
||||||
/// surfaces. No shadow, no outline: [color] defaults to the app's own
|
|
||||||
/// `surface` token (so this panel reads as light or dark to match the
|
|
||||||
/// active theme, like the rest of the media viewer, instead of a fixed
|
|
||||||
/// dark tone regardless of theme) at a higher-than-usual [opacity], since
|
|
||||||
/// a light tint needs denser coverage than a near-black one did to stay
|
|
||||||
/// legible over arbitrary photo/video brightness underneath.
|
|
||||||
class FrostedGlassContainer extends StatelessWidget {
|
class FrostedGlassContainer extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
@@ -20,14 +13,12 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
||||||
final BorderRadius? radius;
|
final BorderRadius? radius;
|
||||||
|
|
||||||
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
/// Blur sigma for the backdrop filter. Defaults to the shared frost setting.
|
||||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
final double? blurSigma;
|
||||||
final double blurSigma;
|
|
||||||
|
|
||||||
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to a fixed
|
/// Opacity (0-1) of the tonal fill behind the blur. Defaults to the shared frost setting.
|
||||||
/// value; pass an explicit value (e.g. from user settings) to make it
|
final double? opacity;
|
||||||
/// adjustable.
|
final bool? frosted;
|
||||||
final double opacity;
|
|
||||||
|
|
||||||
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
|
/// The tint under the blur. Defaults to [NooColors.surface] (resolved at
|
||||||
/// build time, so it always matches the active theme) - pass an explicit
|
/// build time, so it always matches the active theme) - pass an explicit
|
||||||
@@ -39,8 +30,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
required this.child,
|
required this.child,
|
||||||
this.borderRadius = 0,
|
this.borderRadius = 0,
|
||||||
this.radius,
|
this.radius,
|
||||||
this.blurSigma = 20,
|
this.blurSigma,
|
||||||
this.opacity = 0.8,
|
this.opacity,
|
||||||
|
this.frosted,
|
||||||
this.color,
|
this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,18 +40,28 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final shape = radius ?? BorderRadius.circular(borderRadius);
|
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||||
final tint = color ?? context.nooColors.surface;
|
final tint = color ?? context.nooColors.surface;
|
||||||
|
final settings = context.watch<SettingsController?>();
|
||||||
|
final enabled = frosted ?? settings?.bottomBarFrosted ?? false;
|
||||||
|
final sigma = blurSigma ?? settings?.bottomBarFrostedBlur ?? 20;
|
||||||
|
final fill = Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: enabled
|
||||||
|
? tint.withValues(
|
||||||
|
alpha: opacity ?? settings?.bottomBarFrostedOpacity ?? 0.72,
|
||||||
|
)
|
||||||
|
: tint,
|
||||||
|
borderRadius: shape,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: shape,
|
borderRadius: shape,
|
||||||
child: BackdropFilter(
|
child: enabled
|
||||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
? BackdropFilter(
|
||||||
child: Container(
|
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||||
decoration: BoxDecoration(
|
child: fill,
|
||||||
color: tint.withValues(alpha: opacity),
|
)
|
||||||
borderRadius: shape,
|
: fill,
|
||||||
),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
enum NooButtonVariant { primary, tonal, secondary, danger, outline, text, textDanger }
|
enum NooButtonVariant {
|
||||||
|
primary,
|
||||||
|
tonal,
|
||||||
|
secondary,
|
||||||
|
danger,
|
||||||
|
outline,
|
||||||
|
text,
|
||||||
|
textDanger,
|
||||||
|
}
|
||||||
|
|
||||||
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
|
enum NooButtonSize { cta, card, field, toolbar, compact, xs }
|
||||||
|
|
||||||
@@ -37,6 +45,14 @@ class NooButton extends StatefulWidget {
|
|||||||
final bool disabled;
|
final bool disabled;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// A round, icon-only button (no label) - [icon] centred in a circle as
|
||||||
|
/// wide as the size's height, instead of the pill's side padding. Callers
|
||||||
|
/// are responsible for a tooltip/semantic label, since there's no text.
|
||||||
|
final bool iconOnly;
|
||||||
|
|
||||||
|
/// Continuous icon-only to labelled expansion for animated shell actions.
|
||||||
|
final double? expansion;
|
||||||
|
|
||||||
const NooButton({
|
const NooButton({
|
||||||
super.key,
|
super.key,
|
||||||
this.variant = NooButtonVariant.primary,
|
this.variant = NooButtonVariant.primary,
|
||||||
@@ -46,6 +62,8 @@ class NooButton extends StatefulWidget {
|
|||||||
this.fullWidth = false,
|
this.fullWidth = false,
|
||||||
this.disabled = false,
|
this.disabled = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.iconOnly = false,
|
||||||
|
this.expansion,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -87,17 +105,29 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final s = _sizes[widget.size]!;
|
final s = _sizes[widget.size]!;
|
||||||
final (bg, fg, border) = _colors(colors);
|
final (bg, fg, border) = _colors(colors);
|
||||||
final hasLeadingIcon = widget.icon != null && widget.child != null && !_isTextVariant;
|
final hasLeadingIcon =
|
||||||
|
widget.icon != null && widget.child != null && !_isTextVariant;
|
||||||
|
|
||||||
final content = Row(
|
final content = Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
|
if (widget.icon != null) Icon(widget.icon, size: s.icon, color: fg),
|
||||||
if (widget.icon != null && widget.child != null) SizedBox(width: s.gap),
|
if (widget.icon != null && widget.child != null)
|
||||||
if (widget.child != null)
|
SizedBox(width: s.gap * (widget.expansion ?? 1)),
|
||||||
DefaultTextStyle(
|
if (widget.child != null && widget.expansion != 0)
|
||||||
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
ClipRect(
|
||||||
child: widget.child!,
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
widthFactor: widget.expansion ?? 1,
|
||||||
|
heightFactor: 1,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: widget.expansion ?? 1,
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: NooText.buttonSm.copyWith(fontSize: s.font, color: fg),
|
||||||
|
child: widget.child!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -107,7 +137,9 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
onTapDown: widget.disabled ? null : (_) => setState(() => _down = true),
|
||||||
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
|
onTapUp: widget.disabled ? null : (_) => setState(() => _down = false),
|
||||||
onTapCancel: widget.disabled ? null : () => setState(() => _down = false),
|
onTapCancel: widget.disabled
|
||||||
|
? null
|
||||||
|
: () => setState(() => _down = false),
|
||||||
onTap: widget.disabled ? null : widget.onTap,
|
onTap: widget.disabled ? null : widget.onTap,
|
||||||
child: AnimatedScale(
|
child: AnimatedScale(
|
||||||
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
|
scale: _down && !widget.disabled ? NooMotion.pressScale : 1,
|
||||||
@@ -115,8 +147,20 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
curve: NooMotion.ease,
|
curve: NooMotion.ease,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: s.height,
|
height: s.height,
|
||||||
width: widget.fullWidth ? double.infinity : null,
|
width: widget.iconOnly
|
||||||
padding: _isTextVariant
|
? s.height
|
||||||
|
: (widget.fullWidth ? double.infinity : null),
|
||||||
|
padding: widget.expansion != null
|
||||||
|
? EdgeInsets.only(
|
||||||
|
left:
|
||||||
|
(s.height - s.icon) / 2 +
|
||||||
|
(s.px - 4 - (s.height - s.icon) / 2) *
|
||||||
|
widget.expansion!,
|
||||||
|
right:
|
||||||
|
(s.height - s.icon) / 2 +
|
||||||
|
(s.px - (s.height - s.icon) / 2) * widget.expansion!,
|
||||||
|
)
|
||||||
|
: _isTextVariant || widget.iconOnly
|
||||||
? EdgeInsets.zero
|
? EdgeInsets.zero
|
||||||
: EdgeInsets.only(
|
: EdgeInsets.only(
|
||||||
left: hasLeadingIcon ? s.px - 4 : s.px,
|
left: hasLeadingIcon ? s.px - 4 : s.px,
|
||||||
@@ -128,7 +172,9 @@ class _NooButtonState extends State<NooButton> {
|
|||||||
// of shrink-wrapping to it, which is what stretched this button
|
// of shrink-wrapping to it, which is what stretched this button
|
||||||
// edge-to-edge instead of sizing to its content (see NooFab's
|
// edge-to-edge instead of sizing to its content (see NooFab's
|
||||||
// doc comment for the same bug).
|
// doc comment for the same bug).
|
||||||
alignment: widget.fullWidth ? Alignment.center : null,
|
alignment: widget.fullWidth || widget.iconOnly
|
||||||
|
? Alignment.center
|
||||||
|
: null,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
borderRadius: BorderRadius.circular(NooRadii.pill),
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// A continuous (or [divisions]-stepped) value slider in the design system's
|
||||||
|
/// colors: accent fill on a `surface3` track, accent thumb. Data-agnostic -
|
||||||
|
/// the caller owns [value] and formats any readout beside it.
|
||||||
|
class NooSlider extends StatelessWidget {
|
||||||
|
final double value;
|
||||||
|
final double min;
|
||||||
|
final double max;
|
||||||
|
final int? divisions;
|
||||||
|
final ValueChanged<double>? onChanged;
|
||||||
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
const NooSlider({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
required this.min,
|
||||||
|
required this.max,
|
||||||
|
this.divisions,
|
||||||
|
this.onChanged,
|
||||||
|
this.semanticLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 4,
|
||||||
|
activeTrackColor: colors.accent,
|
||||||
|
inactiveTrackColor: colors.surface3,
|
||||||
|
thumbColor: colors.accent,
|
||||||
|
overlayColor: colors.accent.withValues(alpha: 0.12),
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 9),
|
||||||
|
trackShape: const RoundedRectSliderTrackShape(),
|
||||||
|
showValueIndicator: ShowValueIndicator.never,
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: value.clamp(min, max),
|
||||||
|
min: min,
|
||||||
|
max: max,
|
||||||
|
divisions: divisions,
|
||||||
|
semanticFormatterCallback: semanticLabel == null
|
||||||
|
? null
|
||||||
|
: (v) => '$semanticLabel ${v.toStringAsFixed(2)}',
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ const int _kDesktopInlineActions = 4;
|
|||||||
/// Replaces the bare row of Material `IconButton`s each screen used to
|
/// Replaces the bare row of Material `IconButton`s each screen used to
|
||||||
/// build ad hoc.
|
/// build ad hoc.
|
||||||
///
|
///
|
||||||
/// A `surface` card (radius 20) - selection reads as a distinct mode, not
|
/// A floating `surface` card (radius 20), with the bottom bar's border
|
||||||
|
/// and shadow - selection reads as a distinct mode, not
|
||||||
/// just a row of buttons floating on `bg` - with a close circle, the
|
/// just a row of buttons floating on `bg` - with a close circle, the
|
||||||
/// count, and the bulk actions. This sits in the *content* pane's own
|
/// count, and the bulk actions. This sits in the *content* pane's own
|
||||||
/// pinned header slot on every platform (the shell's desktop toolbar,
|
/// pinned header slot on every platform (the shell's desktop toolbar,
|
||||||
@@ -87,6 +88,8 @@ class NooSelectionBar extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: colors.surface,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
boxShadow: const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final Widget label;
|
final Widget label;
|
||||||
final Widget? subtitle;
|
final Widget? subtitle;
|
||||||
|
|
||||||
/// Renders as `value` text + a trailing chevron - mutually exclusive with
|
/// Renders as `value` text + a trailing chevron - mutually exclusive with
|
||||||
/// [trailing] (a `NooToggle`, button, badge, etc.).
|
/// [trailing] (a `NooToggle`, button, badge, etc.).
|
||||||
final String? value;
|
final String? value;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
final bool danger;
|
final bool danger;
|
||||||
|
|
||||||
/// Accent-text action row, e.g. "Add account".
|
/// Accent-text action row, e.g. "Add account".
|
||||||
final bool accent;
|
final bool accent;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
|
||||||
const NooSettingsRow({
|
const NooSettingsRow({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -29,12 +32,15 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
this.danger = false,
|
this.danger = false,
|
||||||
this.accent = false,
|
this.accent = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final color = danger ? colors.danger : (accent ? colors.accentText : colors.fg1);
|
final color = danger
|
||||||
|
? colors.danger
|
||||||
|
: (accent ? colors.accentText : colors.fg1);
|
||||||
final minHeight = subtitle != null ? 60.0 : 52.0;
|
final minHeight = subtitle != null ? 60.0 : 52.0;
|
||||||
|
|
||||||
Widget? trail = trailing;
|
Widget? trail = trailing;
|
||||||
@@ -44,7 +50,11 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
value!,
|
value!,
|
||||||
style: NooText.body.copyWith(fontSize: 15, height: 1, color: colors.fg3),
|
style: NooText.body.copyWith(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1,
|
||||||
|
color: colors.fg3,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
|
Icon(LucideIcons.chevronRight, size: 18, color: colors.fg3),
|
||||||
@@ -53,7 +63,7 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: colors.surface,
|
color: backgroundColor ?? colors.surface,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
@@ -66,7 +76,11 @@ class NooSettingsRow extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (icon != null) ...[
|
if (icon != null) ...[
|
||||||
Icon(icon, size: 20, color: danger || accent ? color : colors.fg2),
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: danger || accent ? color : colors.fg2,
|
||||||
|
),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
import 'noo_nav_style.dart';
|
import 'noo_nav_style.dart';
|
||||||
@@ -18,14 +19,17 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// what order, is the caller's business; this only draws [destinations].
|
/// what order, is the caller's business; this only draws [destinations].
|
||||||
///
|
///
|
||||||
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
|
/// - [NooNavStyle.ios]: surface fill, 1px top `line`, a 50px row (8px top
|
||||||
/// padding) of icon 24 over a 10px label. Active: accent-text, 600.
|
/// padding) of icon 24 over a 10px selected label. Active: accent-text, 600.
|
||||||
/// Idle: fg-3, 500. No ripple.
|
/// Idle: centered fg-3 icon, no label. No ripple.
|
||||||
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
|
/// - [NooNavStyle.android]: surface fill, 80px. Every tab shows its 24px
|
||||||
/// icon over a 12px label (label only visible - not removed from layout,
|
/// icon over a 12px label (label only visible - not removed from layout,
|
||||||
/// so the row never resizes - once selected). The active icon sits in a
|
/// so the row never resizes - once selected). The active icon sits in a
|
||||||
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
||||||
/// rather than popping in/out on the destination item itself.
|
/// rather than popping in/out on the destination item itself.
|
||||||
///
|
///
|
||||||
|
/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android
|
||||||
|
/// row below, so iOS gets the same icon-only idle tabs.
|
||||||
|
///
|
||||||
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||||
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||||
@@ -45,6 +49,13 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// body draws behind it - see `tab_state_slivers.dart`'s
|
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||||
/// `bottomBarClearance`.
|
/// `bottomBarClearance`.
|
||||||
///
|
///
|
||||||
|
/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a
|
||||||
|
/// translucent one over a backdrop blur, on both bar styles and both nav
|
||||||
|
/// styles, and drops [nooDialogShadow] (a shadow would show through the
|
||||||
|
/// glass). It only reads as glass if content draws behind the bar, so the
|
||||||
|
/// host `Scaffold` needs `extendBody: true` and each scrollable body needs
|
||||||
|
/// the same trailing clearance floating does - see [drawsBehindBody].
|
||||||
|
///
|
||||||
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||||
@@ -54,31 +65,59 @@ const double _kAndroidPillHeight = 32;
|
|||||||
class NooBottomBar extends StatelessWidget {
|
class NooBottomBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final NooBottomBarStyle barStyle;
|
final NooBottomBarStyle barStyle;
|
||||||
|
final bool frosted;
|
||||||
|
|
||||||
|
/// Backdrop blur sigma and surface-fill opacity while [frosted] (user-
|
||||||
|
/// adjustable in Settings; the defaults match [FrostedGlassContainer]'s
|
||||||
|
/// blur).
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
final List<NooNavDestination> destinations;
|
final List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
final NooNavDestination? searchDestination;
|
final NooNavDestination? searchDestination;
|
||||||
final VoidCallback? onSearchTap;
|
final VoidCallback? onSearchTap;
|
||||||
|
|
||||||
|
/// Bottom-avatar navigation occupies this slot while closed. Search moves
|
||||||
|
/// from the row into the slot as the avatar expands into its popup.
|
||||||
|
final Widget? avatarSatellite;
|
||||||
|
final double avatarMenuProgress;
|
||||||
|
|
||||||
const NooBottomBar({
|
const NooBottomBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
this.barStyle = NooBottomBarStyle.attached,
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
|
this.frosted = false,
|
||||||
|
this.frostedBlur = defaultFrostedBlur,
|
||||||
|
this.frostedOpacity = defaultFrostedOpacity,
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
this.searchDestination,
|
this.searchDestination,
|
||||||
this.onSearchTap,
|
this.onSearchTap,
|
||||||
|
this.avatarSatellite,
|
||||||
|
this.avatarMenuProgress = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
||||||
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||||
/// much clearance (see the class doc comment) instead of guessing.
|
/// much clearance (see the class doc comment) instead of guessing.
|
||||||
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
||||||
if (style == NooNavStyle.ios) return 50;
|
if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) {
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const double defaultFrostedBlur = 20;
|
||||||
|
static const double defaultFrostedOpacity = 0.72;
|
||||||
|
|
||||||
|
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||||
|
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||||
|
/// frosted bar (there's nothing to blur otherwise).
|
||||||
|
static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) =>
|
||||||
|
frosted || barStyle == NooBottomBarStyle.floating;
|
||||||
|
|
||||||
/// Gap between the floating bar's bottom edge and the safe area below it
|
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||||
static const double floatingBottomMargin = 12;
|
static const double floatingBottomMargin = 12;
|
||||||
@@ -86,13 +125,20 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final ios = style == NooNavStyle.ios;
|
|
||||||
final floating = barStyle == NooBottomBarStyle.floating;
|
final floating = barStyle == NooBottomBarStyle.floating;
|
||||||
|
// Floating always gets the Android row (icon-only idle tabs, sliding
|
||||||
|
// pill) - iOS's icon-over-label row doesn't fit a pill that short.
|
||||||
|
final ios = style == NooNavStyle.ios && !floating;
|
||||||
final hasSearch = searchDestination != null && onSearchTap != null;
|
final hasSearch = searchDestination != null && onSearchTap != null;
|
||||||
// Attached folds Search into the row itself (last item); floating
|
// Attached folds Search into the row itself (last item); floating
|
||||||
// gives it a separate satellite circle instead (built below), so the
|
// gives it a separate satellite circle instead (built below), so the
|
||||||
// row builders only ever see it as a trailing item in the former case.
|
// row builders only ever see it as a trailing item in the former case.
|
||||||
final rowSearch = hasSearch && !floating ? searchDestination : null;
|
final rowSearch =
|
||||||
|
hasSearch &&
|
||||||
|
(!floating || avatarSatellite != null) &&
|
||||||
|
(avatarSatellite == null || !floating || avatarMenuProgress < 1)
|
||||||
|
? searchDestination
|
||||||
|
: null;
|
||||||
|
|
||||||
final row = ios
|
final row = ios
|
||||||
? _buildIosRow(trailingSearch: rowSearch)
|
? _buildIosRow(trailingSearch: rowSearch)
|
||||||
@@ -104,17 +150,21 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||||
|
|
||||||
if (floating) {
|
if (floating) {
|
||||||
final pill = Container(
|
final pillRadius = BorderRadius.circular(28);
|
||||||
|
final pillBox = Container(
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: Border.all(color: colors.line),
|
border: Border.all(color: colors.line),
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: pillRadius,
|
||||||
boxShadow: const [nooDialogShadow],
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
),
|
),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
|
final pill = frosted
|
||||||
|
? _blurred(pillBox, pillRadius, frostedBlur)
|
||||||
|
: pillBox;
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -124,7 +174,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
16,
|
16,
|
||||||
NooBottomBar.floatingBottomMargin,
|
NooBottomBar.floatingBottomMargin,
|
||||||
),
|
),
|
||||||
child: hasSearch
|
child: avatarSatellite != null
|
||||||
|
? _withAvatar(pill, barHeight, hasSearch)
|
||||||
|
: hasSearch
|
||||||
// A fixed-height SizedBox, not just a Row with
|
// A fixed-height SizedBox, not just a Row with
|
||||||
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
||||||
// gives this widget a *loose* (unbounded-max) height
|
// gives this widget a *loose* (unbounded-max) height
|
||||||
@@ -145,6 +197,9 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
destination: searchDestination!,
|
destination: searchDestination!,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
size: barHeight,
|
size: barHeight,
|
||||||
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -154,39 +209,133 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Container(
|
final bar = Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: _fill(colors),
|
||||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: SizedBox(height: barHeight, child: row),
|
child: SizedBox(
|
||||||
|
height: barHeight,
|
||||||
|
child: avatarSatellite == null
|
||||||
|
? row
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: row),
|
||||||
|
SizedBox(
|
||||||
|
width: barHeight,
|
||||||
|
height: barHeight,
|
||||||
|
child: avatarSatellite,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return frosted ? _blurred(bar, BorderRadius.zero, frostedBlur) : bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _withAvatar(Widget rail, double height, bool hasSearch) {
|
||||||
|
final satelliteWidth = hasSearch
|
||||||
|
? height
|
||||||
|
: height * (1 - avatarMenuProgress);
|
||||||
|
return SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: rail),
|
||||||
|
SizedBox(width: 8 * (hasSearch ? 1 : 1 - avatarMenuProgress)),
|
||||||
|
SizedBox(
|
||||||
|
width: satelliteWidth,
|
||||||
|
height: height,
|
||||||
|
child: ClipRect(
|
||||||
|
child: OverflowBox(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
minWidth: height,
|
||||||
|
maxWidth: height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
avatarSatellite!,
|
||||||
|
if (hasSearch && avatarMenuProgress > 0)
|
||||||
|
Opacity(
|
||||||
|
opacity: avatarMenuProgress,
|
||||||
|
child: _SearchSatellite(
|
||||||
|
destination: searchDestination!,
|
||||||
|
onTap: onSearchTap!,
|
||||||
|
size: height,
|
||||||
|
frosted: frosted,
|
||||||
|
frostedBlur: frostedBlur,
|
||||||
|
frostedOpacity: frostedOpacity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color _fill(NooColors colors) => frosted
|
||||||
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
|
: colors.surface;
|
||||||
|
|
||||||
|
double get _rowSearchFraction =>
|
||||||
|
avatarSatellite == null || barStyle == NooBottomBarStyle.attached
|
||||||
|
? 1
|
||||||
|
: 1 - avatarMenuProgress;
|
||||||
|
|
||||||
|
Widget _searchRowItem(Widget child, double width) => SizedBox(
|
||||||
|
width: width * _rowSearchFraction,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _rowSearchFraction < .95,
|
||||||
|
child: ClipRect(
|
||||||
|
child: OverflowBox(
|
||||||
|
minWidth: width,
|
||||||
|
maxWidth: width,
|
||||||
|
child: Opacity(opacity: _rowSearchFraction, child: child),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
return Row(
|
return LayoutBuilder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
builder: (context, constraints) {
|
||||||
children: [
|
final width =
|
||||||
for (var i = 0; i < destinations.length; i++)
|
constraints.maxWidth /
|
||||||
Expanded(
|
(destinations.length +
|
||||||
child: _IosItem(
|
(trailingSearch == null ? 0 : _rowSearchFraction));
|
||||||
destination: destinations[i],
|
return Row(
|
||||||
selected: i == selectedIndex,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
onTap: () => onSelected(i),
|
children: [
|
||||||
),
|
for (var i = 0; i < destinations.length; i++)
|
||||||
),
|
SizedBox(
|
||||||
if (trailingSearch != null)
|
width: width,
|
||||||
Expanded(
|
child: _IosItem(
|
||||||
child: _IosItem(
|
destination: destinations[i],
|
||||||
destination: trailingSearch,
|
selected: i == selectedIndex,
|
||||||
selected: false,
|
selectedLabelOnly: true,
|
||||||
onTap: onSearchTap!,
|
onTap: () => onSelected(i),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (trailingSearch != null)
|
||||||
|
_searchRowItem(
|
||||||
|
_IosItem(
|
||||||
|
destination: trailingSearch,
|
||||||
|
selected: false,
|
||||||
|
selectedLabelOnly: true,
|
||||||
|
onTap: onSearchTap!,
|
||||||
|
),
|
||||||
|
width,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +345,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
NooNavDestination? trailingSearch,
|
NooNavDestination? trailingSearch,
|
||||||
}) {
|
}) {
|
||||||
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
||||||
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
|
final itemCount =
|
||||||
|
destinations.length + (trailingSearch != null ? _rowSearchFraction : 0);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final itemWidth = constraints.maxWidth / itemCount;
|
final itemWidth = constraints.maxWidth / itemCount;
|
||||||
@@ -222,7 +372,8 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < destinations.length; i++)
|
for (var i = 0; i < destinations.length; i++)
|
||||||
Expanded(
|
SizedBox(
|
||||||
|
width: itemWidth,
|
||||||
child: _AndroidItem(
|
child: _AndroidItem(
|
||||||
destination: destinations[i],
|
destination: destinations[i],
|
||||||
selected: i == selectedIndex,
|
selected: i == selectedIndex,
|
||||||
@@ -231,13 +382,14 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (trailingSearch != null)
|
if (trailingSearch != null)
|
||||||
Expanded(
|
_searchRowItem(
|
||||||
child: _AndroidItem(
|
_AndroidItem(
|
||||||
destination: trailingSearch,
|
destination: trailingSearch,
|
||||||
selected: false,
|
selected: false,
|
||||||
floating: floating,
|
floating: floating,
|
||||||
onTap: onSearchTap!,
|
onTap: onSearchTap!,
|
||||||
),
|
),
|
||||||
|
itemWidth,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -248,6 +400,16 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||||
|
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
||||||
|
Widget _blurred(Widget child, BorderRadius radius, double sigma) => ClipRRect(
|
||||||
|
borderRadius: radius,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// The floating bar's own Search entry point, next to the pill rather than
|
/// The floating bar's own Search entry point, next to the pill rather than
|
||||||
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||||
/// same fill/border as the pill so the two still read as one family.
|
/// same fill/border as the pill so the two still read as one family.
|
||||||
@@ -255,11 +417,17 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final double size;
|
final double size;
|
||||||
|
final bool frosted;
|
||||||
|
final double frostedBlur;
|
||||||
|
final double frostedOpacity;
|
||||||
|
|
||||||
const _SearchSatellite({
|
const _SearchSatellite({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.size,
|
required this.size,
|
||||||
|
required this.frosted,
|
||||||
|
required this.frostedBlur,
|
||||||
|
required this.frostedOpacity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -271,31 +439,42 @@ class _SearchSatellite extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: _satelliteBody(colors),
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surface,
|
|
||||||
border: Border.all(color: colors.line),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: const [nooDialogShadow],
|
|
||||||
),
|
|
||||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _satelliteBody(NooColors colors) {
|
||||||
|
final disc = Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: frosted
|
||||||
|
? colors.surface.withValues(alpha: frostedOpacity)
|
||||||
|
: colors.surface,
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||||
|
),
|
||||||
|
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||||
|
);
|
||||||
|
return frosted
|
||||||
|
? _blurred(disc, BorderRadius.circular(size / 2), frostedBlur)
|
||||||
|
: disc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IosItem extends StatelessWidget {
|
class _IosItem extends StatelessWidget {
|
||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final bool selectedLabelOnly;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _IosItem({
|
const _IosItem({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
|
this.selectedLabelOnly = false,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,30 +485,35 @@ class _IosItem extends StatelessWidget {
|
|||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
|
label: selectedLabelOnly && !selected ? destination.label : null,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: selectedLabelOnly && !selected
|
||||||
padding: const EdgeInsets.only(top: 8),
|
? Center(child: Icon(destination.icon, size: 24, color: fg))
|
||||||
child: Column(
|
: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
children: [
|
child: Column(
|
||||||
Icon(destination.icon, size: 24, color: fg),
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
Text(
|
Icon(destination.icon, size: 24, color: fg),
|
||||||
destination.label,
|
const SizedBox(height: 4),
|
||||||
maxLines: 1,
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
destination.label,
|
||||||
style: NooText.navLabel.copyWith(
|
maxLines: 1,
|
||||||
fontSize: 10,
|
overflow: TextOverflow.ellipsis,
|
||||||
height: 1,
|
style: NooText.navLabel.copyWith(
|
||||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
fontSize: 10,
|
||||||
color: fg,
|
height: 1,
|
||||||
|
fontWeight: selected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w500,
|
||||||
|
color: fg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ enum NooBottomBarStyle { attached, floating }
|
|||||||
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
||||||
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
||||||
/// drops that icon entirely and repurposes the avatar button - already
|
/// drops that icon entirely and repurposes the avatar button - already
|
||||||
/// sitting top-right, already a tap target every user already knows - as
|
/// available at the top or bottom by user preference - as the one entry
|
||||||
/// the one entry point instead, opening a dropdown anchored below it (see
|
/// point instead, expanding into an anchored navigation card (see
|
||||||
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
||||||
/// sliding from the opposite edge. Applies on both platforms, since
|
/// sliding from the opposite edge. Applies on both platforms, since
|
||||||
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
|
/// Icons inherit the bar's icon color (accent-text on iOS, fg-1 on
|
||||||
/// Android) through [IconTheme].
|
/// Android) through [IconTheme].
|
||||||
final List<Widget> actions;
|
final List<Widget> actions;
|
||||||
|
final bool iosActionsInTitleRow;
|
||||||
|
|
||||||
/// iOS only: the search field shown below the large title.
|
/// iOS only: the search field shown below the large title.
|
||||||
final Widget? search;
|
final Widget? search;
|
||||||
@@ -76,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
this.leading,
|
this.leading,
|
||||||
this.onMenu,
|
this.onMenu,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
|
this.iosActionsInTitleRow = false,
|
||||||
this.search,
|
this.search,
|
||||||
this.androidTitleTrailing,
|
this.androidTitleTrailing,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
@@ -86,6 +88,11 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
@override
|
@override
|
||||||
Size get preferredSize {
|
Size get preferredSize {
|
||||||
if (!_ios) return const Size.fromHeight(_androidRow);
|
if (!_ios) return const Size.fromHeight(_androidRow);
|
||||||
|
if (iosActionsInTitleRow) {
|
||||||
|
return Size.fromHeight(
|
||||||
|
64 + (search != null ? _iosSearch + _iosSearchBottom : 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Size.fromHeight(
|
return Size.fromHeight(
|
||||||
_iosRow +
|
_iosRow +
|
||||||
_iosTitleTop +
|
_iosTitleTop +
|
||||||
@@ -127,6 +134,50 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIos(NooColors colors, Widget? lead) {
|
Widget _buildIos(NooColors colors, Widget? lead) {
|
||||||
|
if (iosActionsInTitleRow) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsetsDirectional.only(
|
||||||
|
start: lead == null ? 16 : 6,
|
||||||
|
end: 6,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
spacing: 4,
|
||||||
|
children: [
|
||||||
|
?lead,
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.largeTitle.copyWith(
|
||||||
|
leadingDistribution: TextLeadingDistribution.even,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...actions,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (search != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.md,
|
||||||
|
0,
|
||||||
|
NooSpace.md,
|
||||||
|
_iosSearchBottom,
|
||||||
|
),
|
||||||
|
child: SizedBox(height: _iosSearch, child: search),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Pins selection actions above the shell navigation. Scaffold supplies its
|
||||||
|
/// navigation footprint through MediaQuery padding when the body extends
|
||||||
|
/// behind navigation; otherwise the body already ends above it.
|
||||||
|
class SelectionBarOverlay extends StatelessWidget {
|
||||||
|
static const clearance = 64.0;
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final Widget? bar;
|
||||||
|
|
||||||
|
const SelectionBarOverlay({super.key, required this.child, this.bar});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (bar == null) return child;
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: MediaQuery.paddingOf(context).bottom + 8,
|
||||||
|
child: SizedBox(height: 56, child: bar),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import '../../models/selection_action.dart';
|
|||||||
import '../../providers/settings_controller.dart';
|
import '../../providers/settings_controller.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/lists/noo_tab_order_row.dart';
|
import '../noo/lists/noo_tab_order_row.dart';
|
||||||
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
|
import '../noo/lists/noo_grouped_list.dart';
|
||||||
|
import '../noo/core/noo_segmented_control.dart';
|
||||||
import '../noo/noo_layout.dart';
|
import '../noo/noo_layout.dart';
|
||||||
import 'settings_section.dart';
|
|
||||||
|
|
||||||
/// Settings section: the priority order bulk actions (favorite, share,
|
/// Settings section: the priority order bulk actions (favorite, share,
|
||||||
/// download, ...) show in on the Files/Photos/Favorites multi-select
|
/// download, ...) show in on the Files/Photos/Favorites multi-select
|
||||||
@@ -13,7 +15,7 @@ import 'settings_section.dart';
|
|||||||
/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
|
/// which ones land in `NooSelectionBar`'s fixed inline slots (3 mobile / 4
|
||||||
/// desktop, see its own doc comment) vs. behind its trailing "More" -
|
/// desktop, see its own doc comment) vs. behind its trailing "More" -
|
||||||
/// there's no pin/hide concept here the way Tabs has, so this is just a
|
/// there's no pin/hide concept here the way Tabs has, so this is just a
|
||||||
/// reorder list, not a whole section of its own option rows.
|
/// position choice followed by a reorder list.
|
||||||
class SettingsActionBarSection extends StatelessWidget {
|
class SettingsActionBarSection extends StatelessWidget {
|
||||||
const SettingsActionBarSection({super.key});
|
const SettingsActionBarSection({super.key});
|
||||||
|
|
||||||
@@ -33,18 +35,30 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
final desktop = NooLayout.isDesktop(context);
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final orderList = _ActionOrderList(
|
final orderList = _ActionOrderList(
|
||||||
|
rounded: desktop,
|
||||||
order: settings.selectionActionOrder,
|
order: settings.selectionActionOrder,
|
||||||
onReorder: (oldIndex, newIndex) =>
|
onReorder: (oldIndex, newIndex) =>
|
||||||
_handleReorder(settings, oldIndex, newIndex),
|
_handleReorder(settings, oldIndex, newIndex),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final positionRow = NooSettingsRow(
|
||||||
|
label: const Text('Position'),
|
||||||
|
trailing: NooSegmentedControl<SelectionBarPosition>(
|
||||||
|
onSurface: true,
|
||||||
|
value: settings.selectionBarPosition,
|
||||||
|
onChanged: settings.setSelectionBarPosition,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(value: SelectionBarPosition.top, label: 'Top'),
|
||||||
|
NooSegmentOption(value: SelectionBarPosition.bottom, label: 'Bottom'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
|
|
||||||
if (!desktop) {
|
if (!desktop) {
|
||||||
return SettingsSection(
|
return NooGroupedList(
|
||||||
title: 'Action bar',
|
footer: const Text(_subtitle),
|
||||||
subtitle: _subtitle,
|
children: [positionRow, orderList],
|
||||||
children: [orderList],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +79,8 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
Text(_subtitle, style: NooText.meta.copyWith(color: colors.fg3)),
|
||||||
const SizedBox(height: NooSpace.md),
|
const SizedBox(height: NooSpace.md),
|
||||||
|
positionRow,
|
||||||
|
const SizedBox(height: NooSpace.md),
|
||||||
orderList,
|
orderList,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -79,8 +95,13 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
class _ActionOrderList extends StatelessWidget {
|
class _ActionOrderList extends StatelessWidget {
|
||||||
final List<SelectionActionKind> order;
|
final List<SelectionActionKind> order;
|
||||||
final void Function(int oldIndex, int newIndex) onReorder;
|
final void Function(int oldIndex, int newIndex) onReorder;
|
||||||
|
final bool rounded;
|
||||||
|
|
||||||
const _ActionOrderList({required this.order, required this.onReorder});
|
const _ActionOrderList({
|
||||||
|
required this.order,
|
||||||
|
required this.onReorder,
|
||||||
|
required this.rounded,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -105,12 +126,14 @@ class _ActionOrderList extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
final rows = DecoratedBox(
|
||||||
|
decoration: BoxDecoration(color: colors.line),
|
||||||
|
child: list,
|
||||||
|
);
|
||||||
|
if (!rounded) return rows;
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
child: DecoratedBox(
|
child: rows,
|
||||||
decoration: BoxDecoration(color: colors.line),
|
|
||||||
child: list,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../providers/settings_controller.dart';
|
|||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/core/noo_segmented_control.dart';
|
import '../noo/core/noo_segmented_control.dart';
|
||||||
|
import '../noo/core/noo_slider.dart';
|
||||||
import '../noo/core/noo_toggle.dart';
|
import '../noo/core/noo_toggle.dart';
|
||||||
import '../noo/lists/noo_settings_row.dart';
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
import '../noo/nav/noo_nav_style.dart';
|
import '../noo/nav/noo_nav_style.dart';
|
||||||
@@ -53,7 +54,36 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeRow(settings: settings),
|
_ThemeRow(settings: settings),
|
||||||
_BottomBarStyleRow(settings: settings),
|
_BottomBarStyleRow(settings: settings),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.glassWater,
|
||||||
|
label: const Text('Frosted glass'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Blurred bottom navigation, avatar menus and media viewer panels',
|
||||||
|
),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: settings.bottomBarFrosted,
|
||||||
|
onChanged: settings.setBottomBarFrosted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||||
_NavMenuStyleRow(settings: settings),
|
_NavMenuStyleRow(settings: settings),
|
||||||
|
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.userRound,
|
||||||
|
label: const Text('Avatar position'),
|
||||||
|
trailing: NooSegmentedControl<AvatarPosition>(
|
||||||
|
onSurface: true,
|
||||||
|
value: settings.avatarPosition,
|
||||||
|
onChanged: settings.setAvatarPosition,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(value: AvatarPosition.top, label: 'Top'),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: AvatarPosition.bottom,
|
||||||
|
label: 'Bottom',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
label: const Text('Search in bottom bar'),
|
label: const Text('Search in bottom bar'),
|
||||||
@@ -83,14 +113,19 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
icon: LucideIcons.palette,
|
icon: LucideIcons.palette,
|
||||||
label: const Text('Accent color'),
|
label: const Text('Accent color'),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
settings.useDynamicColor
|
settings.useDynamicColor &&
|
||||||
|
Theme.of(context).platform != TargetPlatform.iOS
|
||||||
? 'Matching your wallpaper'
|
? 'Matching your wallpaper'
|
||||||
: settings.seedColor == AppTheme.defaultAccent
|
: settings.seedColor == AppTheme.defaultAccent
|
||||||
? 'Default'
|
? 'Default'
|
||||||
: 'Custom color',
|
: 'Custom color',
|
||||||
),
|
),
|
||||||
trailing: _AccentSwatchDot(
|
trailing: _AccentSwatchDot(
|
||||||
color: settings.useDynamicColor ? null : settings.seedColor,
|
color:
|
||||||
|
settings.useDynamicColor &&
|
||||||
|
Theme.of(context).platform != TargetPlatform.iOS
|
||||||
|
? null
|
||||||
|
: settings.seedColor,
|
||||||
),
|
),
|
||||||
onTap: () => _openAccentPicker(context, settings),
|
onTap: () => _openAccentPicker(context, settings),
|
||||||
),
|
),
|
||||||
@@ -103,15 +138,16 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
onChanged: settings.setAmoledDark,
|
onChanged: settings.setAmoledDark,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
NooSettingsRow(
|
if (Theme.of(context).platform != TargetPlatform.iOS)
|
||||||
icon: LucideIcons.waves,
|
NooSettingsRow(
|
||||||
label: const Text('Seek bar style'),
|
icon: LucideIcons.waves,
|
||||||
subtitle: const Text(
|
label: const Text('Seek bar style'),
|
||||||
'The progress bar style used when playing videos',
|
subtitle: const Text(
|
||||||
|
'The progress bar style used when playing videos',
|
||||||
|
),
|
||||||
|
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
|
||||||
|
onTap: () => _openSeekBarPicker(context, settings),
|
||||||
),
|
),
|
||||||
value: _seekBarStyleLabel(settings.mediaProgressBarStyle),
|
|
||||||
onTap: () => _openSeekBarPicker(context, settings),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -155,6 +191,7 @@ class _ThemeRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<ThemeMode>(
|
NooSegmentedControl<ThemeMode>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.themeMode,
|
value: settings.themeMode,
|
||||||
onChanged: settings.setThemeMode,
|
onChanged: settings.setThemeMode,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -218,6 +255,7 @@ class _BottomBarStyleRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<NooBottomBarStyle>(
|
NooSegmentedControl<NooBottomBarStyle>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.bottomBarStyle,
|
value: settings.bottomBarStyle,
|
||||||
onChanged: settings.setBottomBarStyle,
|
onChanged: settings.setBottomBarStyle,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -240,6 +278,118 @@ class _BottomBarStyleRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How strong the frosted-glass bottom bar looks: three presets for a quick
|
||||||
|
/// pick, and the blur / opacity sliders they set, for anything in between.
|
||||||
|
/// Only shown while frosted glass is on.
|
||||||
|
class _FrostedGlassRow extends StatelessWidget {
|
||||||
|
final SettingsController settings;
|
||||||
|
|
||||||
|
const _FrostedGlassRow({required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Material(
|
||||||
|
color: colors.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.sparkles, size: 20, color: colors.fg2),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Text(
|
||||||
|
'Frost strength',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
NooSegmentedControl<FrostedGlassPreset?>(
|
||||||
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
|
// No segment selected once the sliders no longer match a preset.
|
||||||
|
value: settings.frostedPreset,
|
||||||
|
onChanged: (preset) {
|
||||||
|
if (preset != null) settings.setFrostedPreset(preset);
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
for (final preset in FrostedGlassPreset.values)
|
||||||
|
NooSegmentOption<FrostedGlassPreset?>(
|
||||||
|
value: preset,
|
||||||
|
label: preset.label,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SliderLine(
|
||||||
|
label: 'Blur',
|
||||||
|
readout: settings.bottomBarFrostedBlur.round().toString(),
|
||||||
|
slider: NooSlider(
|
||||||
|
value: settings.bottomBarFrostedBlur,
|
||||||
|
min: minFrostedBlur,
|
||||||
|
max: maxFrostedBlur,
|
||||||
|
semanticLabel: 'Blur',
|
||||||
|
onChanged: settings.setFrostedBlur,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SliderLine(
|
||||||
|
label: 'Opacity',
|
||||||
|
readout: '${(settings.bottomBarFrostedOpacity * 100).round()}%',
|
||||||
|
slider: NooSlider(
|
||||||
|
value: settings.bottomBarFrostedOpacity,
|
||||||
|
min: minFrostedOpacity,
|
||||||
|
max: maxFrostedOpacity,
|
||||||
|
semanticLabel: 'Opacity',
|
||||||
|
onChanged: settings.setFrostedOpacity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SliderLine extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String readout;
|
||||||
|
final Widget slider;
|
||||||
|
|
||||||
|
const _SliderLine({
|
||||||
|
required this.label,
|
||||||
|
required this.readout,
|
||||||
|
required this.slider,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 64,
|
||||||
|
child: Text(label, style: NooText.body.copyWith(color: colors.fg2)),
|
||||||
|
),
|
||||||
|
Expanded(child: slider),
|
||||||
|
SizedBox(
|
||||||
|
width: 44,
|
||||||
|
child: Text(
|
||||||
|
readout,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
||||||
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
||||||
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
||||||
@@ -278,6 +428,7 @@ class _NavMenuStyleRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<NooNavMenuStyle>(
|
NooSegmentedControl<NooNavMenuStyle>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.navMenuStyle,
|
value: settings.navMenuStyle,
|
||||||
onChanged: settings.setNavMenuStyle,
|
onChanged: settings.setNavMenuStyle,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -331,6 +482,7 @@ class _FabStyleRow extends StatelessWidget {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
NooSegmentedControl<FabStyle>(
|
NooSegmentedControl<FabStyle>(
|
||||||
fill: true,
|
fill: true,
|
||||||
|
onSurface: true,
|
||||||
value: settings.fabStyle,
|
value: settings.fabStyle,
|
||||||
onChanged: settings.setFabStyle,
|
onChanged: settings.setFabStyle,
|
||||||
options: const [
|
options: const [
|
||||||
@@ -384,22 +536,26 @@ class _AccentSwatchDot extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
void _openAccentPicker(BuildContext context, SettingsController settings) {
|
||||||
|
final useDynamicColor =
|
||||||
|
settings.useDynamicColor &&
|
||||||
|
Theme.of(context).platform != TargetPlatform.iOS;
|
||||||
Widget grid(NooColors colors) => Wrap(
|
Widget grid(NooColors colors) => Wrap(
|
||||||
spacing: 14,
|
spacing: 14,
|
||||||
runSpacing: 14,
|
runSpacing: 14,
|
||||||
children: [
|
children: [
|
||||||
_AccentSwatch(
|
if (Theme.of(context).platform != TargetPlatform.iOS)
|
||||||
isSelected: settings.useDynamicColor,
|
_AccentSwatch(
|
||||||
onTap: () {
|
isSelected: settings.useDynamicColor,
|
||||||
settings.setUseDynamicColor(true);
|
onTap: () {
|
||||||
Navigator.pop(context);
|
settings.setUseDynamicColor(true);
|
||||||
},
|
Navigator.pop(context);
|
||||||
background: colors.surface3,
|
},
|
||||||
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
|
background: colors.surface3,
|
||||||
),
|
child: Icon(LucideIcons.wallpaper, color: colors.fg2, size: 20),
|
||||||
|
),
|
||||||
for (final color in AppTheme.seedColors)
|
for (final color in AppTheme.seedColors)
|
||||||
_AccentSwatch(
|
_AccentSwatch(
|
||||||
isSelected: !settings.useDynamicColor && settings.seedColor == color,
|
isSelected: !useDynamicColor && settings.seedColor == color,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
settings.setSeedColor(color);
|
settings.setSeedColor(color);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
|||||||
@@ -125,12 +125,16 @@ class _CacheIntervalRow extends StatelessWidget {
|
|||||||
onChanged: (value) => onChanged(value.round()),
|
onChanged: (value) => onChanged(value.round()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (minutes < 15)
|
if (Theme.of(context).platform == TargetPlatform.iOS ||
|
||||||
|
(Theme.of(context).platform == TargetPlatform.android && minutes < 15))
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'While the app is closed, synced files refresh at most every '
|
Theme.of(context).platform == TargetPlatform.iOS
|
||||||
'15 min (an Android limit).',
|
? 'iOS decides when background sync runs; the refresh '
|
||||||
|
'interval is a request, not a guaranteed schedule.'
|
||||||
|
: 'While the app is closed, synced files refresh at most every '
|
||||||
|
'15 min (an Android limit).',
|
||||||
style: NooText.meta.copyWith(color: colors.fg3),
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -26,26 +26,28 @@ String biometricLabel(TargetPlatform platform) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settings section 3: login lock, which gates opening the app, switching
|
/// Settings section 3: three independent locks - opening the app, switching
|
||||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
/// accounts, and revealing hidden files - each behind the device's own PIN/
|
||||||
/// biometric credential (see `AppLockService` - this app never stores or
|
/// biometric credential (see `AppLockService` - this app never stores or
|
||||||
/// handles a PIN itself).
|
/// handles a PIN itself). Turning one on or off asks for that credential, and
|
||||||
|
/// none of them requires or implies another.
|
||||||
class SettingsSecuritySection extends StatelessWidget {
|
class SettingsSecuritySection extends StatelessWidget {
|
||||||
const SettingsSecuritySection({super.key});
|
const SettingsSecuritySection({super.key});
|
||||||
|
|
||||||
Future<void> _handleLoginLockChanged(
|
Future<void> _handleLockChanged(
|
||||||
BuildContext context,
|
BuildContext context, {
|
||||||
SessionController session,
|
required Future<bool> Function() change,
|
||||||
bool value,
|
required bool turningOn,
|
||||||
) async {
|
required String name,
|
||||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
}) async {
|
||||||
|
final success = await change();
|
||||||
if (!success && context.mounted) {
|
if (!success && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
value
|
turningOn
|
||||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||||
: 'Could not turn off login lock',
|
: 'Could not turn off $name',
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -64,10 +66,17 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.lock,
|
icon: LucideIcons.lock,
|
||||||
label: Text(biometricLabel(platform)),
|
label: Text(biometricLabel(platform)),
|
||||||
subtitle: const Text("Require this device's PIN or biometric to open Noo"),
|
subtitle: const Text(
|
||||||
|
"Require this device's PIN or biometric to open Noo",
|
||||||
|
),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.loginLockEnabled,
|
checked: session.loginLockEnabled,
|
||||||
onChanged: (value) => _handleLoginLockChanged(context, session, value),
|
onChanged: (value) => _handleLockChanged(
|
||||||
|
context,
|
||||||
|
change: value ? session.setupLoginLock : session.disableLoginLock,
|
||||||
|
turningOn: value,
|
||||||
|
name: 'login lock',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
@@ -76,7 +85,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.lockAccountSwitching,
|
checked: session.lockAccountSwitching,
|
||||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
onChanged: (value) => _handleLockChanged(
|
||||||
|
context,
|
||||||
|
change: () => session.setLockAccountSwitching(value),
|
||||||
|
turningOn: value,
|
||||||
|
name: 'account switching lock',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
@@ -85,7 +99,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.lockHiddenFiles,
|
checked: session.lockHiddenFiles,
|
||||||
onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null,
|
onChanged: (value) => _handleLockChanged(
|
||||||
|
context,
|
||||||
|
change: () => session.setLockHiddenFiles(value),
|
||||||
|
turningOn: value,
|
||||||
|
name: 'hidden files lock',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import '../theme/design_tokens.dart';
|
|||||||
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
final double height;
|
final double height;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final bool floating;
|
||||||
|
|
||||||
const StickyHeaderDelegate({required this.height, required this.child});
|
const StickyHeaderDelegate({
|
||||||
|
required this.height,
|
||||||
|
required this.child,
|
||||||
|
this.floating = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
double get minExtent => height;
|
double get minExtent => height;
|
||||||
@@ -38,7 +43,7 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|||||||
// Colors.transparent` guards against the same M3 elevation-tint
|
// Colors.transparent` guards against the same M3 elevation-tint
|
||||||
// behavior even if this ever gets a non-zero elevation.
|
// behavior even if this ever gets a non-zero elevation.
|
||||||
return Material(
|
return Material(
|
||||||
color: context.nooColors.bg,
|
color: floating ? Colors.transparent : context.nooColors.bg,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
@@ -46,6 +51,8 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
bool shouldRebuild(covariant StickyHeaderDelegate oldDelegate) {
|
||||||
return oldDelegate.height != height || oldDelegate.child != child;
|
return oldDelegate.height != height ||
|
||||||
|
oldDelegate.child != child ||
|
||||||
|
oldDelegate.floating != floating;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
|||||||
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||||
/// Scaffold already shrinks the body above them - this is then pure
|
/// Scaffold already shrinks the body above them - this is then pure
|
||||||
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
||||||
/// bars draw over an extended body instead, so nothing reserves space for
|
/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an
|
||||||
|
/// extended body instead, so nothing reserves space for
|
||||||
/// them automatically: the clearance has to cover the bar's own footprint
|
/// them automatically: the clearance has to cover the bar's own footprint
|
||||||
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||||
/// the safe-area inset below it, or the last row ends up hidden under it.
|
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||||
double bottomBarClearance(BuildContext context) {
|
double bottomBarClearance(BuildContext context) {
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
final barStyle = settings.bottomBarStyle;
|
||||||
|
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
final barHeight = NooBottomBar.rowHeight(
|
final barHeight = NooBottomBar.rowHeight(
|
||||||
NooLayout.navStyle(context),
|
NooLayout.navStyle(context),
|
||||||
NooBottomBarStyle.floating,
|
barStyle,
|
||||||
);
|
);
|
||||||
|
final margin = barStyle == NooBottomBarStyle.floating
|
||||||
|
? NooBottomBar.floatingBottomMargin
|
||||||
|
: 0;
|
||||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||||
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
return barHeight + margin + safeBottom + 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||||
|
|||||||
@@ -4,14 +4,8 @@ import '../../theme/design_tokens.dart';
|
|||||||
import '../frosted_glass_container.dart';
|
import '../frosted_glass_container.dart';
|
||||||
import 'viewer_icon_button.dart';
|
import 'viewer_icon_button.dart';
|
||||||
|
|
||||||
/// The bottom panel overlaid on the media viewer: an optional transport row
|
/// A full-width media panel containing transport, actions and details.
|
||||||
/// ([transportControls], video only) above the action row
|
/// Its solid/frosted surface follows the shared appearance preference.
|
||||||
/// (share/favorite/open/download/delete/details) - one continuous flush,
|
|
||||||
/// full-width `FrostedGlassContainer` panel, not two separate floating
|
|
||||||
/// pills. Kept on that blurred chrome as a deliberate exception to the
|
|
||||||
/// design system's flat product UI (see `file_viewer_screen.dart`'s
|
|
||||||
/// build() comment for why), unlike the now-flat bottom nav bar
|
|
||||||
/// (`bottom_nav_bar.dart`) this screen sits above.
|
|
||||||
class MediaActionBar extends StatelessWidget {
|
class MediaActionBar extends StatelessWidget {
|
||||||
final bool isFavorite;
|
final bool isFavorite;
|
||||||
final bool isBusy;
|
final bool isBusy;
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
||||||
import '../../models/nextcloud_item.dart';
|
import '../../models/nextcloud_item.dart';
|
||||||
import '../../providers/session_controller.dart';
|
import '../../providers/session_controller.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/core/noo_button.dart';
|
|
||||||
|
|
||||||
/// Space the viewer's floating top bar and bottom action bar cover, so the
|
/// Space the viewer's floating top bar and bottom action bar cover, so the
|
||||||
/// text sits clear of them (and the status bar) instead of scrolling
|
/// text sits clear of them (and the status bar) instead of scrolling
|
||||||
@@ -14,16 +12,97 @@ import '../noo/core/noo_button.dart';
|
|||||||
const double _kTopBarClearance = 72;
|
const double _kTopBarClearance = 72;
|
||||||
const double _kActionBarClearance = 112;
|
const double _kActionBarClearance = 112;
|
||||||
|
|
||||||
|
/// What the viewer's top bar needs from [MediaTextPreview]: whether to show
|
||||||
|
/// the Edit/Preview toggle and the Save button, their current state, and a way
|
||||||
|
/// to trigger them. The preview owns the text, the editing mode and the save;
|
||||||
|
/// this just mirrors that out so `FileViewerScreen` can draw the buttons in
|
||||||
|
/// its own top bar (they used to float inside the preview).
|
||||||
|
class TextPreviewController extends ChangeNotifier {
|
||||||
|
bool _canToggleEditing = false;
|
||||||
|
bool _editing = false;
|
||||||
|
bool _dirty = false;
|
||||||
|
bool _saving = false;
|
||||||
|
bool _disposed = false;
|
||||||
|
VoidCallback? _onToggleEditing;
|
||||||
|
Future<void> Function()? _onSave;
|
||||||
|
|
||||||
|
/// Markdown, writable (not the read-only Offline copy) and loaded - the
|
||||||
|
/// Edit/Preview toggle only exists then.
|
||||||
|
bool get canToggleEditing => _canToggleEditing;
|
||||||
|
|
||||||
|
/// Markdown is currently showing its raw text (the toggle then offers
|
||||||
|
/// "Preview"); false while rendered (it offers "Edit").
|
||||||
|
bool get editing => _editing;
|
||||||
|
|
||||||
|
/// The text differs from what's saved on the server.
|
||||||
|
bool get dirty => _dirty;
|
||||||
|
bool get saving => _saving;
|
||||||
|
|
||||||
|
/// The Save button is shown once the content has changed, and stays (as a
|
||||||
|
/// spinner) while the save is in flight.
|
||||||
|
bool get showSave => _dirty || _saving;
|
||||||
|
|
||||||
|
void toggleEditing() => _onToggleEditing?.call();
|
||||||
|
Future<void> save() async => _onSave?.call();
|
||||||
|
|
||||||
|
void _bind(VoidCallback toggleEditing, Future<void> Function() save) {
|
||||||
|
_onToggleEditing = toggleEditing;
|
||||||
|
_onSave = save;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The preview went away: nothing to toggle or save any more. Quiet (no
|
||||||
|
/// notification) - it happens while the tree is being torn down.
|
||||||
|
void _unbind() {
|
||||||
|
_onToggleEditing = null;
|
||||||
|
_onSave = null;
|
||||||
|
_canToggleEditing = false;
|
||||||
|
_editing = false;
|
||||||
|
_dirty = false;
|
||||||
|
_saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _update({
|
||||||
|
required bool canToggleEditing,
|
||||||
|
required bool editing,
|
||||||
|
required bool dirty,
|
||||||
|
required bool saving,
|
||||||
|
}) {
|
||||||
|
if (_canToggleEditing == canToggleEditing &&
|
||||||
|
_editing == editing &&
|
||||||
|
_dirty == dirty &&
|
||||||
|
_saving == saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_canToggleEditing = canToggleEditing;
|
||||||
|
_editing = editing;
|
||||||
|
_dirty = dirty;
|
||||||
|
_saving = saving;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_disposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
||||||
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
||||||
/// editable when online; a Save button appears once it has changed. Markdown
|
/// editable when online - after tapping Edit; every file opens read-only
|
||||||
/// files open rendered, with an Edit/Preview toggle. Opened
|
/// (Markdown rendered) with an Edit/Preview toggle - and Save appears once it
|
||||||
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
|
/// has changed. Both controls live in the viewer's
|
||||||
/// local copy has no server to write back to.
|
/// top bar, driven through [controller]. Opened from the Offline tab
|
||||||
|
/// ([localPathResolver] set) it is read-only, since the local copy has no
|
||||||
|
/// server to write back to.
|
||||||
class MediaTextPreview extends StatefulWidget {
|
class MediaTextPreview extends StatefulWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
final SessionController session;
|
final SessionController session;
|
||||||
|
|
||||||
|
/// Receives this preview's editing/save state and is how the top bar's
|
||||||
|
/// buttons reach it. Optional so the preview also works on its own.
|
||||||
|
final TextPreviewController? controller;
|
||||||
|
|
||||||
/// When set, read bytes from the local path it resolves to instead of
|
/// When set, read bytes from the local path it resolves to instead of
|
||||||
/// fetching from the server - see
|
/// fetching from the server - see
|
||||||
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
||||||
@@ -33,6 +112,7 @@ class MediaTextPreview extends StatefulWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.session,
|
required this.session,
|
||||||
|
this.controller,
|
||||||
this.localPathResolver,
|
this.localPathResolver,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,12 +126,15 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
bool _editing = false;
|
bool _editing = false;
|
||||||
|
final _focus = FocusNode();
|
||||||
|
|
||||||
bool get _isMarkdown {
|
bool get _isMarkdown {
|
||||||
final name = widget.item.name.toLowerCase();
|
final name = widget.item.name.toLowerCase();
|
||||||
return name.endsWith('.md') || name.endsWith('.markdown');
|
return name.endsWith('.md') || name.endsWith('.markdown');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Markdown shows rendered until the user taps Edit. Plain text has no
|
||||||
|
/// rendered form, so it just shows its (read-only) text until then.
|
||||||
bool get _showRendered => _isMarkdown && !_editing;
|
bool get _showRendered => _isMarkdown && !_editing;
|
||||||
|
|
||||||
bool get _readOnly => widget.localPathResolver != null;
|
bool get _readOnly => widget.localPathResolver != null;
|
||||||
@@ -60,16 +143,45 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller.addListener(() => setState(() {}));
|
widget.controller?._bind(_toggleEditing, _save);
|
||||||
|
_controller.addListener(() {
|
||||||
|
setState(() {});
|
||||||
|
_syncToolbar();
|
||||||
|
});
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
widget.controller?._unbind();
|
||||||
|
_focus.dispose();
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirrors the editing/save state out to the viewer's top bar. Only called
|
||||||
|
/// from event handlers and async completions - never from build or
|
||||||
|
/// initState, where notifying the bar's listeners would be illegal.
|
||||||
|
void _syncToolbar() {
|
||||||
|
widget.controller?._update(
|
||||||
|
canToggleEditing: _saved != null && !_readOnly,
|
||||||
|
editing: _editing,
|
||||||
|
dirty: _dirty,
|
||||||
|
saving: _isSaving,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleEditing() {
|
||||||
|
setState(() => _editing = !_editing);
|
||||||
|
_syncToolbar();
|
||||||
|
// Tapping Edit should put the cursor in the text, not just unlock it.
|
||||||
|
if (_editing) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
|
(_) => _focus.requestFocus(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
try {
|
try {
|
||||||
final List<int> bytes;
|
final List<int> bytes;
|
||||||
@@ -89,6 +201,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
final text = utf8.decode(bytes, allowMalformed: true);
|
final text = utf8.decode(bytes, allowMalformed: true);
|
||||||
_controller.text = text;
|
_controller.text = text;
|
||||||
setState(() => _saved = text);
|
setState(() => _saved = text);
|
||||||
|
_syncToolbar();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = e.toString());
|
if (mounted) setState(() => _error = e.toString());
|
||||||
@@ -99,6 +212,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final text = _controller.text;
|
final text = _controller.text;
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
|
_syncToolbar();
|
||||||
var ok = false;
|
var ok = false;
|
||||||
try {
|
try {
|
||||||
ok = await widget.session.service!.putBytes(
|
ok = await widget.session.service!.putBytes(
|
||||||
@@ -111,6 +225,7 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
_isSaving = false;
|
_isSaving = false;
|
||||||
if (ok) _saved = text;
|
if (ok) _saved = text;
|
||||||
});
|
});
|
||||||
|
_syncToolbar();
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
||||||
@@ -158,7 +273,10 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
padding: padding,
|
padding: padding,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
readOnly: _readOnly,
|
focusNode: _focus,
|
||||||
|
// Read-only until Edit is tapped (always, for the
|
||||||
|
// Offline copy).
|
||||||
|
readOnly: _readOnly || !_editing,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
style: style,
|
style: style,
|
||||||
@@ -171,30 +289,6 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_isMarkdown && !_readOnly)
|
|
||||||
Positioned(
|
|
||||||
right: 20,
|
|
||||||
top: safe.top + _kTopBarClearance - 8,
|
|
||||||
child: NooButton(
|
|
||||||
variant: NooButtonVariant.tonal,
|
|
||||||
size: NooButtonSize.compact,
|
|
||||||
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
|
|
||||||
onTap: () => setState(() => _editing = !_editing),
|
|
||||||
child: Text(_editing ? 'Preview' : 'Edit'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_dirty)
|
|
||||||
Positioned(
|
|
||||||
right: 20,
|
|
||||||
bottom: safe.bottom + _kActionBarClearance,
|
|
||||||
child: NooButton(
|
|
||||||
size: NooButtonSize.card,
|
|
||||||
icon: LucideIcons.save,
|
|
||||||
disabled: _isSaving,
|
|
||||||
onTap: _isSaving ? null : _save,
|
|
||||||
child: Text(_isSaving ? 'Saving…' : 'Save'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
@@ -198,12 +199,14 @@ class VideoTransportControls extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
child: WavySeekBar(
|
child: Theme.of(context).platform == TargetPlatform.iOS
|
||||||
controller: controller,
|
? IosSeekBar(controller: controller, color: fg)
|
||||||
playedColor: fg,
|
: WavySeekBar(
|
||||||
trackColor: fg.withValues(alpha: 0.3),
|
controller: controller,
|
||||||
style: progressBarStyle,
|
playedColor: fg,
|
||||||
),
|
trackColor: fg.withValues(alpha: 0.3),
|
||||||
|
style: progressBarStyle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -213,6 +216,60 @@ class VideoTransportControls extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class IosSeekBar extends StatefulWidget {
|
||||||
|
final VideoPlayerController controller;
|
||||||
|
final Color color;
|
||||||
|
const IosSeekBar({super.key, required this.controller, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<IosSeekBar> createState() => _IosSeekBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IosSeekBarState extends State<IosSeekBar> {
|
||||||
|
double? _dragRatio;
|
||||||
|
bool _resumeAfterScrubbing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final value = widget.controller.value;
|
||||||
|
final durationMs = value.duration.inMilliseconds;
|
||||||
|
final ratio =
|
||||||
|
(_dragRatio ??
|
||||||
|
(durationMs > 0
|
||||||
|
? value.position.inMilliseconds / durationMs
|
||||||
|
: 0.0))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: CupertinoSlider(
|
||||||
|
value: ratio,
|
||||||
|
activeColor: widget.color,
|
||||||
|
thumbColor: CupertinoColors.white,
|
||||||
|
onChangeStart: durationMs <= 0
|
||||||
|
? null
|
||||||
|
: (_) {
|
||||||
|
_resumeAfterScrubbing = value.isPlaying;
|
||||||
|
if (_resumeAfterScrubbing) widget.controller.pause();
|
||||||
|
},
|
||||||
|
onChanged: durationMs <= 0
|
||||||
|
? null
|
||||||
|
: (ratio) {
|
||||||
|
setState(() => _dragRatio = ratio);
|
||||||
|
widget.controller.seekTo(value.duration * ratio);
|
||||||
|
},
|
||||||
|
onChangeEnd: durationMs <= 0
|
||||||
|
? null
|
||||||
|
: (ratio) async {
|
||||||
|
await widget.controller.seekTo(value.duration * ratio);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _dragRatio = null);
|
||||||
|
if (_resumeAfterScrubbing) widget.controller.play();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Material You "expressive" wavy seek bar: the played portion of the
|
/// A Material You "expressive" wavy seek bar: the played portion of the
|
||||||
/// track animates as a travelling sine wave while playing and settles flat
|
/// track animates as a travelling sine wave while playing and settles flat
|
||||||
/// when paused, matching the Android 16+ media player style. Drag or tap
|
/// when paused, matching the Android 16+ media player style. Drag or tap
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.2+5
|
version: 1.0.2+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/models/nextcloud_item.dart';
|
||||||
|
import 'package:noo/providers/connectivity_controller.dart';
|
||||||
|
import 'package:noo/providers/files_controller.dart';
|
||||||
|
import 'package:noo/providers/session_controller.dart';
|
||||||
|
|
||||||
|
NextcloudItem _item(String path, {String? mountType}) => NextcloudItem(
|
||||||
|
id: path,
|
||||||
|
name: path.split('/').last,
|
||||||
|
path: path,
|
||||||
|
type: NextcloudItemType.file,
|
||||||
|
size: 1,
|
||||||
|
lastModified: DateTime(2026),
|
||||||
|
mountType: mountType,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and
|
||||||
|
/// how a pref saved before the three-way hidden filter is read back.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||||
|
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||||
|
);
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||||
|
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||||
|
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
final items = [
|
||||||
|
_item('/a.txt'),
|
||||||
|
_item('/.secret'),
|
||||||
|
_item('/docs/.git/config'),
|
||||||
|
_item('/nas', mountType: 'external'),
|
||||||
|
_item('/.nas-hidden', mountType: 'external'),
|
||||||
|
];
|
||||||
|
List<String> paths(List<NextcloudItem> l) => l.map((i) => i.path).toList();
|
||||||
|
|
||||||
|
FilesController build() =>
|
||||||
|
FilesController(SessionController(ConnectivityController()));
|
||||||
|
|
||||||
|
test('hidden filter: hide / only / include', () {
|
||||||
|
final files = build()..setStorageScope(StorageScope.all);
|
||||||
|
List<String> run(HiddenFilesFilter h) => paths(
|
||||||
|
files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h),
|
||||||
|
);
|
||||||
|
expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']);
|
||||||
|
expect(run(HiddenFilesFilter.only), [
|
||||||
|
'/.secret',
|
||||||
|
'/docs/.git/config',
|
||||||
|
'/.nas-hidden',
|
||||||
|
]);
|
||||||
|
expect(run(HiddenFilesFilter.include), paths(items));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage scope: cloud / external / all', () {
|
||||||
|
final files = build();
|
||||||
|
List<String> run(StorageScope s) {
|
||||||
|
files.setStorageScope(s);
|
||||||
|
return paths(
|
||||||
|
files.applyCommonFilters(
|
||||||
|
items,
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
hidden: HiddenFilesFilter.include,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(run(StorageScope.cloud), [
|
||||||
|
'/a.txt',
|
||||||
|
'/.secret',
|
||||||
|
'/docs/.git/config',
|
||||||
|
]);
|
||||||
|
expect(run(StorageScope.external), ['/nas', '/.nas-hidden']);
|
||||||
|
expect(run(StorageScope.all), paths(items));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hidden + external combine', () {
|
||||||
|
final files = build()..setStorageScope(StorageScope.external);
|
||||||
|
expect(
|
||||||
|
paths(
|
||||||
|
files.applyCommonFilters(
|
||||||
|
items,
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
hidden: HiddenFilesFilter.only,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
['/.nas-hidden'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('loadHiddenFilter', () {
|
||||||
|
Future<HiddenFilesFilter> load(Map<String, Object> values) async {
|
||||||
|
SharedPreferences.setMockInitialValues(values);
|
||||||
|
return FilesController.loadHiddenFilter(
|
||||||
|
await SharedPreferences.getInstance(),
|
||||||
|
'new',
|
||||||
|
'old',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('defaults to hide', () async {
|
||||||
|
expect(await load({}), HiddenFilesFilter.hide);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the old show-hidden bool maps to hide / include', () async {
|
||||||
|
expect(await load({'old': true}), HiddenFilesFilter.include);
|
||||||
|
expect(await load({'old': false}), HiddenFilesFilter.hide);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the new key wins and an unknown value falls back to hide', () async {
|
||||||
|
expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only);
|
||||||
|
expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/providers/connectivity_controller.dart';
|
||||||
|
import 'package:noo/providers/session_controller.dart';
|
||||||
|
import 'package:noo/services/app_lock_service.dart';
|
||||||
|
|
||||||
|
/// The three Settings -> Security locks (open the app, switch accounts, reveal
|
||||||
|
/// hidden files) each work on their own - none needs or implies another. The
|
||||||
|
/// device prompt is replaced with a recorder so the gates can be exercised.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||||
|
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||||
|
);
|
||||||
|
messenger.setMockMethodCallHandler(
|
||||||
|
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||||
|
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||||
|
);
|
||||||
|
messenger.setMockStreamHandler(
|
||||||
|
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||||
|
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
late List<String> prompts;
|
||||||
|
var authSucceeds = true;
|
||||||
|
var deviceSupported = true;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
prompts = [];
|
||||||
|
authSucceeds = true;
|
||||||
|
deviceSupported = true;
|
||||||
|
AppLockService.debugAuthenticate = (reason) async {
|
||||||
|
prompts.add(reason);
|
||||||
|
return authSucceeds;
|
||||||
|
};
|
||||||
|
AppLockService.debugIsDeviceSupported = () async => deviceSupported;
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
AppLockService.debugAuthenticate = null;
|
||||||
|
AppLockService.debugIsDeviceSupported = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
SessionController build() => SessionController(ConnectivityController());
|
||||||
|
|
||||||
|
test(
|
||||||
|
'hidden files and account switching can be locked without login lock',
|
||||||
|
() async {
|
||||||
|
final session = build();
|
||||||
|
|
||||||
|
expect(await session.setLockHiddenFiles(true), isTrue);
|
||||||
|
expect(await session.setLockAccountSwitching(true), isTrue);
|
||||||
|
|
||||||
|
expect(session.lockHiddenFiles, isTrue);
|
||||||
|
expect(session.lockAccountSwitching, isTrue);
|
||||||
|
expect(session.loginLockEnabled, isFalse);
|
||||||
|
expect(
|
||||||
|
session.needsUnlock,
|
||||||
|
isFalse,
|
||||||
|
reason: 'opening the app stays open',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a gate prompts on its own, with login lock off', () async {
|
||||||
|
final session = build();
|
||||||
|
|
||||||
|
expect(await session.passGate(false, 'unused'), isTrue);
|
||||||
|
expect(prompts, isEmpty, reason: 'a gate that is off never prompts');
|
||||||
|
|
||||||
|
expect(await session.passGate(true, 'Unlock to show hidden files'), isTrue);
|
||||||
|
expect(prompts, ['Unlock to show hidden files']);
|
||||||
|
|
||||||
|
authSucceeds = false;
|
||||||
|
expect(await session.passGate(true, 'again'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'turning a lock on needs a capable device and a successful auth',
|
||||||
|
() async {
|
||||||
|
final session = build();
|
||||||
|
|
||||||
|
deviceSupported = false;
|
||||||
|
expect(await session.setLockHiddenFiles(true), isFalse);
|
||||||
|
expect(session.lockHiddenFiles, isFalse);
|
||||||
|
|
||||||
|
deviceSupported = true;
|
||||||
|
authSucceeds = false;
|
||||||
|
expect(await session.setLockHiddenFiles(true), isFalse);
|
||||||
|
expect(session.lockHiddenFiles, isFalse);
|
||||||
|
|
||||||
|
authSucceeds = true;
|
||||||
|
expect(await session.setLockHiddenFiles(true), isTrue);
|
||||||
|
expect(session.lockHiddenFiles, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'turning a lock off needs auth, so it cannot just be flipped away',
|
||||||
|
() async {
|
||||||
|
final session = build();
|
||||||
|
await session.setLockAccountSwitching(true);
|
||||||
|
prompts.clear();
|
||||||
|
|
||||||
|
authSucceeds = false;
|
||||||
|
expect(await session.setLockAccountSwitching(false), isFalse);
|
||||||
|
expect(session.lockAccountSwitching, isTrue);
|
||||||
|
|
||||||
|
authSucceeds = true;
|
||||||
|
expect(await session.setLockAccountSwitching(false), isTrue);
|
||||||
|
expect(session.lockAccountSwitching, isFalse);
|
||||||
|
expect(prompts, hasLength(2));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('setting a lock to its current value does not prompt', () async {
|
||||||
|
final session = build();
|
||||||
|
expect(await session.setLockHiddenFiles(false), isTrue);
|
||||||
|
expect(prompts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('turning login lock off leaves the other two locks alone', () async {
|
||||||
|
final session = build();
|
||||||
|
await session.setupLoginLock();
|
||||||
|
await session.setLockAccountSwitching(true);
|
||||||
|
await session.setLockHiddenFiles(true);
|
||||||
|
expect(session.loginLockEnabled, isTrue);
|
||||||
|
|
||||||
|
expect(await session.disableLoginLock(), isTrue);
|
||||||
|
|
||||||
|
expect(session.loginLockEnabled, isFalse);
|
||||||
|
expect(session.lockAccountSwitching, isTrue);
|
||||||
|
expect(session.lockHiddenFiles, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each lock is saved on its own and restored', () async {
|
||||||
|
final first = build();
|
||||||
|
await first.setLockHiddenFiles(true);
|
||||||
|
// Let the fire-and-forget pref write land.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
final second = build();
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||||
|
expect(second.lockHiddenFiles, isTrue);
|
||||||
|
expect(second.lockAccountSwitching, isFalse);
|
||||||
|
expect(second.loginLockEnabled, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
|
||||||
|
/// The frosted-glass bottom bar's blur/opacity settings and their presets.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
Future<SettingsController> build() async {
|
||||||
|
final settings = SettingsController();
|
||||||
|
// `_load` is async and not exposed; one event-loop turn lets it finish.
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('starts on the Default preset', () async {
|
||||||
|
final settings = await build();
|
||||||
|
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||||
|
expect(settings.bottomBarFrostedBlur, 20);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, 0.72);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets set both values and are recognised again', () async {
|
||||||
|
final settings = await build();
|
||||||
|
for (final preset in FrostedGlassPreset.values) {
|
||||||
|
settings.setFrostedPreset(preset);
|
||||||
|
expect(settings.bottomBarFrostedBlur, preset.blur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, preset.opacity);
|
||||||
|
expect(settings.frostedPreset, preset);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presets run from lighter to stronger frosting', () {
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.less.blur,
|
||||||
|
lessThan(FrostedGlassPreset.standard.blur),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.standard.blur,
|
||||||
|
lessThan(FrostedGlassPreset.more.blur),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.less.opacity,
|
||||||
|
greaterThan(FrostedGlassPreset.standard.opacity),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
FrostedGlassPreset.standard.opacity,
|
||||||
|
greaterThan(FrostedGlassPreset.more.opacity),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moving a slider off every preset leaves none selected', () async {
|
||||||
|
final settings = await build();
|
||||||
|
settings.setFrostedBlur(27);
|
||||||
|
expect(settings.frostedPreset, isNull);
|
||||||
|
settings.setFrostedBlur(FrostedGlassPreset.standard.blur);
|
||||||
|
expect(settings.frostedPreset, FrostedGlassPreset.standard);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('values are clamped to their ranges', () async {
|
||||||
|
final settings = await build();
|
||||||
|
settings
|
||||||
|
..setFrostedBlur(500)
|
||||||
|
..setFrostedOpacity(0);
|
||||||
|
expect(settings.bottomBarFrostedBlur, maxFrostedBlur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, minFrostedOpacity);
|
||||||
|
settings
|
||||||
|
..setFrostedBlur(-3)
|
||||||
|
..setFrostedOpacity(9);
|
||||||
|
expect(settings.bottomBarFrostedBlur, minFrostedBlur);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saved values are restored (and clamped) on the next launch', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ui_bottom_bar_frosted_blur': 32.0,
|
||||||
|
'ui_bottom_bar_frosted_opacity': 7.0,
|
||||||
|
});
|
||||||
|
final settings = await build();
|
||||||
|
expect(settings.bottomBarFrostedBlur, 32);
|
||||||
|
expect(settings.bottomBarFrostedOpacity, maxFrostedOpacity);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test(
|
||||||
|
'defaults to top and safely handles an unknown saved position',
|
||||||
|
() async {
|
||||||
|
for (final saved in [null, 'unknown']) {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'ui_selection_bar_position': ?saved,
|
||||||
|
});
|
||||||
|
final settings = SettingsController();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(settings.selectionBarPosition, SelectionBarPosition.top);
|
||||||
|
settings.dispose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'bottom position persists and restores across controller creation',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final settings = SettingsController();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
settings.setSelectionBarPosition(SelectionBarPosition.bottom);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final restored = SettingsController();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(restored.selectionBarPosition, SelectionBarPosition.bottom);
|
||||||
|
settings.dispose();
|
||||||
|
restored.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'avatar position defaults to top and restores a saved bottom choice',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final settings = SettingsController();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(settings.avatarPosition, AvatarPosition.top);
|
||||||
|
settings.setAvatarPosition(AvatarPosition.bottom);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final restored = SettingsController();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(restored.avatarPosition, AvatarPosition.bottom);
|
||||||
|
settings.dispose();
|
||||||
|
restored.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/services/native_channel.dart';
|
||||||
|
import 'package:noo/services/share_intent_service.dart';
|
||||||
|
|
||||||
|
/// iOS has no handler for the `dev.ayushya.noo/*` channels yet, so these use
|
||||||
|
/// channels with nothing registered - the same `MissingPluginException` the
|
||||||
|
/// simulator showed at startup.
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = MethodChannel('test/no_native_side');
|
||||||
|
const events = EventChannel('test/no_native_side/events');
|
||||||
|
|
||||||
|
test(
|
||||||
|
'invokeIfAvailable returns null when nothing implements the channel',
|
||||||
|
() {
|
||||||
|
expect(
|
||||||
|
invokeIfAvailable<String>(channel, 'anything'),
|
||||||
|
completion(isNull),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('invokeIfAvailable still returns a real result', () async {
|
||||||
|
const live = MethodChannel('test/live');
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(live, (call) async => 'pong');
|
||||||
|
addTearDown(
|
||||||
|
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(live, null),
|
||||||
|
);
|
||||||
|
expect(await invokeIfAvailable<String>(live, 'ping'), 'pong');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invokeOrExplain names the feature the user asked for', () {
|
||||||
|
expect(
|
||||||
|
invokeOrExplain(channel, 'startUpload', 'Uploading'),
|
||||||
|
throwsA(
|
||||||
|
isA<NativeServiceUnavailable>().having(
|
||||||
|
(e) => e.toString(),
|
||||||
|
'message',
|
||||||
|
"Uploading isn't available on this platform yet.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quietEvents swallows the missing-implementation error', () async {
|
||||||
|
final errors = <Object>[];
|
||||||
|
final values = <int>[];
|
||||||
|
final done = Completer<void>();
|
||||||
|
quietEvents<int>(
|
||||||
|
events,
|
||||||
|
(e) => e as int,
|
||||||
|
).listen(values.add, onError: errors.add, onDone: done.complete);
|
||||||
|
// Give the (failing) native `listen` call time to come back.
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(errors, isEmpty);
|
||||||
|
expect(values, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ShareIntentService has nothing to report without a native side', () {
|
||||||
|
expect(ShareIntentService.getInitialShare(), completion(isEmpty));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/services/share_account_service.dart';
|
||||||
|
|
||||||
|
/// What the iOS Share Extension is given - the contract with
|
||||||
|
/// `ios/Shared/SharedAccount.swift`'s `SharedAccounts`.
|
||||||
|
void main() {
|
||||||
|
const alice = ShareAccountEntry(
|
||||||
|
id: 'cloud_example_com__alice',
|
||||||
|
serverUrl: 'https://cloud.example.com',
|
||||||
|
username: 'alice',
|
||||||
|
password: 's3cret',
|
||||||
|
hiddenFilter: 'include',
|
||||||
|
storageScope: 'all',
|
||||||
|
);
|
||||||
|
const bob = ShareAccountEntry(
|
||||||
|
id: 'nc_home_lan__bob',
|
||||||
|
serverUrl: 'https://nc.home.lan:8443/nextcloud',
|
||||||
|
username: 'bob',
|
||||||
|
password: 'pw',
|
||||||
|
hiddenFilter: 'hide',
|
||||||
|
storageScope: 'cloud',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> build({
|
||||||
|
List<ShareAccountEntry> accounts = const [alice, bob],
|
||||||
|
String? activeId = 'nc_home_lan__bob',
|
||||||
|
bool login = true,
|
||||||
|
bool switching = true,
|
||||||
|
bool hidden = false,
|
||||||
|
}) => jsonDecode(
|
||||||
|
ShareAccountService.buildPayload(
|
||||||
|
accounts: accounts,
|
||||||
|
activeId: activeId,
|
||||||
|
loginLockEnabled: login,
|
||||||
|
lockAccountSwitching: switching,
|
||||||
|
lockHiddenFiles: hidden,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('basic auth header matches what the app sends', () {
|
||||||
|
expect(
|
||||||
|
ShareAccountService.basicAuth('alice', 's3cret'),
|
||||||
|
'Basic ${base64Encode(utf8.encode('alice:s3cret'))}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every account is listed with its own header, name and filter', () {
|
||||||
|
final accounts = (build()['accounts'] as List).cast<Map<String, dynamic>>();
|
||||||
|
expect(accounts.map((a) => a['id']), [alice.id, bob.id]);
|
||||||
|
expect(
|
||||||
|
accounts[0]['authHeader'],
|
||||||
|
ShareAccountService.basicAuth('alice', 's3cret'),
|
||||||
|
);
|
||||||
|
expect(accounts[0]['displayName'], 'alice@cloud.example.com');
|
||||||
|
expect(accounts[0]['hiddenFilter'], 'include');
|
||||||
|
expect(accounts[0]['storageScope'], 'all');
|
||||||
|
// The display name uses the host only - not the port or sub-path.
|
||||||
|
expect(accounts[1]['displayName'], 'bob@nc.home.lan');
|
||||||
|
expect(accounts[1]['serverUrl'], 'https://nc.home.lan:8443/nextcloud');
|
||||||
|
expect(accounts[1]['hiddenFilter'], 'hide');
|
||||||
|
expect(accounts[1]['storageScope'], 'cloud');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the active account and the lock settings are passed through', () {
|
||||||
|
final payload = build(login: true, switching: false, hidden: true);
|
||||||
|
expect(payload['activeId'], 'nc_home_lan__bob');
|
||||||
|
expect(payload['loginLockEnabled'], true);
|
||||||
|
expect(payload['lockAccountSwitching'], false);
|
||||||
|
expect(payload['lockHiddenFiles'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no active account is a null activeId, not a missing key', () {
|
||||||
|
expect(build(activeId: null).containsKey('activeId'), true);
|
||||||
|
expect(build(activeId: null)['activeId'], isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||