Compare commits
34
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 mobile/desktop app shell
|
shell/ # pieces shared by the phone/tablet 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
|
||||||
@@ -238,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 (desktop) - see below.
|
sidebar (wide tablet) - 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
|
||||||
@@ -277,7 +277,7 @@ 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 windows get
|
phone in landscape keeps the mobile layout and only tablet-class viewports get
|
||||||
the sidebar):
|
the sidebar):
|
||||||
|
|
||||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
@@ -298,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 desktop and while picking,
|
`topBar` is null (no top bar rendered) on wide tablet 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'
|
||||||
@@ -321,8 +321,8 @@ the sidebar):
|
|||||||
mode moves the avatar into the popup and hands its satellite slot to
|
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
|
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.
|
and expands the popup upward from it. Position is a global setting.
|
||||||
See `styling.md` for motion and dismissal behavior.
|
See `styling.md` (Gotchas) for the wiring, motion and dismissal behavior.
|
||||||
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
- **Wide tablet:** 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
|
||||||
@@ -339,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 desktop), `ShellAvatarButton` and `ShellSearchLauncher`.
|
dialog on wide tablet), `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
|
||||||
@@ -354,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` (desktop) top bar, a noo-styled
|
(mobile) / `NooToolbar` (wide tablet) top bar, a noo-styled
|
||||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) row + folder list
|
||||||
(`NooFileRow` mobile, `NooFileTableRow` desktop - both pickers only ever
|
(`NooFileRow` mobile, `NooFileTableRow` wide tablet - 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`
|
||||||
@@ -401,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 desktop dialog via
|
per-item sheets pick between the mobile sheet and the wide tablet 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. Desktop has no avatar in the
|
on a vertical swipe. Wide tablet 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, Android, macOS and Windows. ("Noo" is a placeholder name.)
|
Noo is a Nextcloud client for iOS and Android. ("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` and `Desktop Screen.dc.html` are the reference builds for each screen.
|
- `Mobile Screen.dc.html` is the reference build for each screen.
|
||||||
- [Noo — Missing Screens](https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4) is a live design canvas (Claude Design, not a static file in this repo) covering pieces this doc originally had no recipe for: Media viewer, Search, the Upload/Move/Copy destination picker, the Details sheet's tab switch, and the Selection action bar (mobile + desktop). It has both an iOS row (built first, ready for later) and an Android row (built to match right now) - same content, chrome adapted per the platform rule above. §4 below is the written-up version of what's approved there; go back to the canvas for pixel-level layout, not just the summary.
|
- [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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ Reference files in this project:
|
|||||||
|
|
||||||
| Token | Role | Light | Dark |
|
| Token | Role | Light | Dark |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `bg` | App background, desktop sidebar | `#F3EFE6` warm off-white | `#0E0D0B` |
|
| `bg` | App background, wide tablet sidebar | `#F3EFE6` warm off-white | `#0E0D0B` |
|
||||||
| `surface` (`sf`) | Cards, rows, bars, sheets, desktop main pane | `#FFFFFF` | `#25241F` |
|
| `surface` (`sf`) | Cards, rows, bars, sheets, wide tablet 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 desktop 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 wide tablet 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 (desktop) / 0.9 | −3% | Offline GB, counters |
|
| Stat | Schibsted 600 | 32 (mobile) · 28 (wide tablet) / 0.9 | −3% | Offline GB, counters |
|
||||||
| Title | Schibsted 500 | 22 / 1 | −2% | Android top bar, desktop toolbar |
|
| Title | Schibsted 500 | 22 / 1 | −2% | Android top bar, wide tablet 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 | Desktop rows, activity text, banners |
|
| Body | Instrument 400/500 | 14–15 / 1.3 | 0 | Wide tablet 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 (desktop) / 1.2 | 0 | Size · date, subtitles (fg-3) |
|
| Meta | Instrument 400 | 13 (mobile) · 12–13 (wide tablet) / 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). Desktop 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). Wide tablet 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 desktop sidebar items and selected table rows
|
- 12px for wide tablet sidebar items and selected table rows
|
||||||
- 28px for the sheet top and drawer edge
|
- 28px for the sheet top and drawer edge
|
||||||
- 24px for desktop dialogs
|
- 24px for wide tablet 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 desktop 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 wide tablet 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 (desktop toolbar), 28–32 (compact).
|
- Always a pill. Heights: 52 (sheet CTA), 44 (mobile card), 40 (inline in a field), 36 (wide tablet 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 desktop.
|
- Idle: surface fill on mobile, or a 1px line border on wide tablet.
|
||||||
- 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 desktop, with a white knob 3px inset.
|
- 48×28 on mobile, 44×26 on wide tablet, 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.
|
||||||
- Desktop: 260px wide in the toolbar.
|
- Wide tablet: 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.
|
||||||
- Desktop: a table row 52px tall.
|
- Wide tablet: 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.
|
||||||
@@ -168,10 +168,10 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
- Shared: `users` in fg-3.
|
- Shared: `users` in fg-3.
|
||||||
|
|
||||||
**Swipe action**
|
**Swipe action**
|
||||||
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent.
|
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; all other actions are white on accent.
|
||||||
- The block has a 20px icon above a 12/600 label.
|
- The block has a 20px icon above a 12/600 label.
|
||||||
- Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it.
|
- Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it.
|
||||||
- Swipe actions are set in Settings → Swipe on a file.
|
- Swipe actions are set in Settings → Swipe on a file. Both directions offer None and every action-bar action: Favorite, Share, Download, Delete, Copy, Move, Rename, Sync to device, Details. They use the same single-item handlers; Delete still asks for confirmation.
|
||||||
|
|
||||||
**Selection action bar** (Files, Photos, Favorites - anywhere with
|
**Selection action bar** (Files, Photos, Favorites - anywhere with
|
||||||
multi-select)
|
multi-select)
|
||||||
@@ -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
|
||||||
desktop) - selection reads as a distinct mode, not a bare row of buttons
|
wide tablet) - 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
|
||||||
desktop - there can be up to 9: favorite, share, download, delete, copy,
|
wide tablet - 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.
|
||||||
- Desktop: inline actions are labelled tonal pills (accent-soft/
|
- Wide tablet: inline actions are labelled tonal pills (accent-soft/
|
||||||
accent-text), danger-soft/danger for the one labelled "Delete" - desktop
|
accent-text), danger-soft/danger for the one labelled "Delete" - wide tablet
|
||||||
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; desktop uses 5 columns with a 16px gap.
|
- Surface fill, radius 18. Mobile uses 2 columns with a 10px gap; wide tablet 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 desktop (8 columns).
|
- Square tiles with a 2px gap on mobile (3 columns, edge to edge) and a 4px gap on wide tablet (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 desktop) and padding of 14–18.
|
- A surface card with radius 20 (surface-2 on wide tablet) 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 (desktop 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 (wide tablet 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 desktop) 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 wide tablet) 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** (desktop)
|
**Dialog** (wide tablet)
|
||||||
- 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,18 +282,18 @@ 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 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 - 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 - "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. |
|
||||||
| Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
|
| Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
|
||||||
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
|
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
|
||||||
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
|
| 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" |
|
| App lock label | "App lock" | "App lock" |
|
||||||
|
|
||||||
### Desktop
|
### Wide tablet
|
||||||
|
|
||||||
Desktop has a 256px sidebar in `bg` and a main pane in `surface`, separated by a 1px line.
|
Wide tablet 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,14 +312,7 @@ 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" |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -327,7 +320,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.
|
||||||
- Desktop adds Modified and Shared filter chips, an active-filter chip, and an item count.
|
- Wide tablet 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
|
||||||
@@ -342,7 +335,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 desktop), then a list whose rows show sync status icons and status meta.
|
- **Offline:** a summary card (or 4 stat cards on wide tablet), 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
|
||||||
@@ -352,8 +345,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. Desktop limits it to 760px wide.
|
- **Activity:** the feed grouped by day. Wide tablet limits it to 760px wide.
|
||||||
- **Trash:** a retention banner, then a list. Mobile rows get a restore icon; desktop rows get a tonal "Restore" pill.
|
- **Trash:** a retention banner, then a list. Mobile rows get a restore icon; wide tablet 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")
|
||||||
@@ -365,8 +358,10 @@ 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 desktop) land in the bar's fixed inline
|
first few (3 mobile / 4 wide tablet) land in the bar's fixed inline
|
||||||
slots, the rest sit behind "More". See §2 "Selection action bar".
|
slots, the rest sit behind "More". A live preview above the list uses
|
||||||
|
the actual bar and updates on reorder, including its More sheet. Preview
|
||||||
|
actions show feedback without touching files. See §2 "Selection action bar".
|
||||||
9. Swipe on a file
|
9. Swipe on a file
|
||||||
|
|
||||||
Mobile is a two-level menu, the way native iOS/Android Settings apps
|
Mobile is a two-level menu, the way native iOS/Android Settings apps
|
||||||
@@ -383,7 +378,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. Desktop is unchanged: a 2-column grid
|
instead of just working around it. Wide tablet 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:
|
||||||
@@ -431,7 +426,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 / desktop table, same as Files), or a centered icon + short
|
(mobile rows / wide tablet 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
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
<!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 / desktop sidebar</div>
|
<div style="font:600 13px/1 var(--ft);color:var(--fg2)">Navigation · iOS tab bar / Android navigation bar / tablet 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, Android, macOS and Windows. 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 and Android. 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,44 +47,20 @@ 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>
|
|
||||||
</sc-for>
|
<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"}}">
|
||||||
</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), screensD: num(Dk),
|
screensM: num(M),
|
||||||
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))
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1064,3 +1064,12 @@ backup) with state in `<...>/sync-state/<accountId>/*.json`.
|
|||||||
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
|
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
|
||||||
|
|
||||||
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
|
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
|
||||||
|
|
||||||
|
Account removal awaits native `removeAccountData` before deleting credentials or
|
||||||
|
account preferences. Native cleanup stops account sync work, takes the sync mutex,
|
||||||
|
and deletes both its local mirror and durable sync records. Signing out continues
|
||||||
|
to retain local data. Cleanup errors keep the saved account available for retry.
|
||||||
|
|
||||||
|
Account display names from the OCS cloud/user response are cached in SavedAccount.displayName and used for account labels/avatars (username fallback). Authentication, IDs, DAV paths and native sync continue using the username. Cached names survive offline startup and inactive accounts; stale responses cannot update another session.
|
||||||
|
|
||||||
|
Settings → Accounts lists the login username explicitly for identifying saved credentials; other account menus continue using the display name.
|
||||||
|
|||||||
+59
-11
@@ -24,7 +24,7 @@ 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 / desktop accent color) is
|
- **Dynamic color** (Android 12+ Material You) 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
|
||||||
@@ -88,7 +88,7 @@ Catalog:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `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` (desktop), `NooSwipeAction` |
|
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (wide tablet), `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` |
|
||||||
@@ -109,8 +109,17 @@ 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
|
- Swipe settings offer every `SelectionActionKind` plus None. `SwipeAction`
|
||||||
built yet. `NooSidebar.windowControls` is the slot for it.
|
persists the existing enum names and maps to that shared action catalog;
|
||||||
|
`FilesView` uses the single-item action-bar handlers, with the existing
|
||||||
|
delete confirmation retained. Labels/icons update for favorited and synced
|
||||||
|
items. Swipes remain disabled during selection, picking, and offline browsing.
|
||||||
|
- Settings → Action bar shows the real `NooSelectionBar` above its reorder
|
||||||
|
list. It updates immediately and supports the same More sheet; preview
|
||||||
|
actions only show feedback, never operate on files. The bar scales down
|
||||||
|
inside Settings' narrower column while keeping the mobile/tablet split.
|
||||||
|
- The Security setting is labelled **App lock** on every platform; its
|
||||||
|
subtitle still explains device PIN or biometric authentication.
|
||||||
- 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`/
|
||||||
@@ -190,7 +199,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 desktop shell chrome.
|
`ShellSearchLauncher`, shared by the mobile and wide tablet 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
|
||||||
@@ -422,16 +431,55 @@ Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flut
|
|||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
## Desktop/tablet layout notes
|
## Wide tablet layout notes
|
||||||
|
|
||||||
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
- The wide tablet 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.
|
||||||
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
- Tablet Settings keeps categories in a sidebar with a compact account card;
|
||||||
pushed category screens render the desktop card layout.
|
the selected category renders inline in the right pane. Phone categories
|
||||||
|
continue to push a separate screen.
|
||||||
- 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,
|
||||||
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
||||||
width, never below the phone count), so a tablet in portrait gets more
|
width, never below the phone count), so wider phone-layout windows get
|
||||||
columns rather than a few huge tiles.
|
more columns rather than a few huge tiles.
|
||||||
|
|
||||||
|
Tablet sidebar layout uses a 600dp minimum shortest window side, regardless of
|
||||||
|
orientation. Portrait iPads and Android tablets therefore keep the sidebar;
|
||||||
|
landscape phones and narrow split-screen windows retain phone navigation.
|
||||||
|
|
||||||
|
About is the last Settings category. `SettingsAboutSection` reads installed
|
||||||
|
version, build number and application identifier through `package_info_plus`,
|
||||||
|
so release build overrides appear correctly without hardcoded version text.
|
||||||
|
|
||||||
|
The tablet Search toolbar is wrapped in `SafeArea` when used as a route's
|
||||||
|
app bar, keeping its field and Close action below the system status bar.
|
||||||
|
|
||||||
|
File tile grids share `NooLayout.fileGridDelegate`: columns derive from the
|
||||||
|
actual content-pane width (220dp maximum tile width), and row heights reserve
|
||||||
|
the thumbnail plus scaled name/meta text. Tablet file tables collapse the date
|
||||||
|
column below 600dp and the second metadata column below 420dp of available pane
|
||||||
|
width, sharing widths between header and
|
||||||
|
rows. PDF viewer top-bar space stays reserved when controls hide. About links
|
||||||
|
open the website, GitHub repository and privacy policy in the external browser.
|
||||||
|
|
||||||
|
Selection bars use the actual content-pane width: compact panes show only
|
||||||
|
the icons that fit, with the rest in More; wide panes measure labels before
|
||||||
|
choosing the inline actions. The selection count truncates rather than overflow.
|
||||||
|
|
||||||
|
Tablet shell sidebars group tab rows in a surface card, put Search below the
|
||||||
|
account card, and pin quota/Settings at the bottom. `TabletAccountMenu` expands
|
||||||
|
inline with AnimatedSize and a rotating chevron for account switching and
|
||||||
|
Add/Manage controls. Tablet Settings uses a category sidebar with a compact
|
||||||
|
account card, showing the selected section in a rounded content pane with a
|
||||||
|
short fade/slide transition; phones retain pushed category screens. Both
|
||||||
|
transitions respect reduced-motion settings. Sidebar widths adapt from 200
|
||||||
|
to 256dp as window width grows.
|
||||||
|
|
||||||
|
Tablet content panes use the theme surface2 tone, including pinned filter headers, to preserve rounded top corners and contrast with surface-colored tiles. Panes fill their allocated width; photo grids derive tile counts from their actual sliver width. Settings category headings appear only in the toolbar, while distinct subsection headings remain.
|
||||||
|
|
||||||
|
Tablet Settings hides bottom-navigation/menu/avatar-position/search-placement controls, upload-button style, swipe-action category, and bottom-tab tap-to-scroll preference. Frosted glass remains available for media viewer panels. Phone preferences stay saved when their controls are hidden.
|
||||||
|
|
||||||
|
Pointer selection: NooPointerSelection tracks Flutter mouse presence and trackpad events across platforms. Selectable file rows, tables, tiles and photos expose separate checkboxes when pointer input is available. Checkbox taps toggle selection without opening content. Offline and single-file picker restrictions are preserved; touch-only long press behavior is unchanged. Trackpads without mouse presence are detected on first use, as Flutter has no universal attached-trackpad enumeration.
|
||||||
|
|||||||
@@ -56,3 +56,6 @@ app.*.map.json
|
|||||||
key.properties
|
key.properties
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|
||||||
|
# Kotlin compiler session cache
|
||||||
|
/android/.kotlin/
|
||||||
|
|||||||
@@ -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, an iOS device, or Windows
|
- An Android device or emulator, or an iOS device or simulator
|
||||||
- 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,7 +57,6 @@ 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
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class ConflictResolveWorker(appContext: Context, params: WorkerParameters) :
|
|||||||
)
|
)
|
||||||
val request = OneTimeWorkRequestBuilder<ConflictResolveWorker>()
|
val request = OneTimeWorkRequestBuilder<ConflictResolveWorker>()
|
||||||
.setInputData(data)
|
.setInputData(data)
|
||||||
|
.addTag("noo_sync_account_$accountId")
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context).enqueue(request)
|
WorkManager.getInstance(context).enqueue(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.ayushya.noo
|
package dev.ayushya.noo
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -140,6 +142,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
|
syncStatusMap(SyncStatusBus.snapshot(), call.argument<String>("accountId")),
|
||||||
)
|
)
|
||||||
"resolveConflict" -> resolveConflict(call, result)
|
"resolveConflict" -> resolveConflict(call, result)
|
||||||
|
"removeAccountData" -> removeAccountData(call, result)
|
||||||
"removeLocalSync" -> removeLocalSync(call, result)
|
"removeLocalSync" -> removeLocalSync(call, result)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
@@ -478,6 +481,32 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
/// walks/deletes a directory tree; `result.success` is posted back via
|
/// walks/deletes a directory tree; `result.success` is posted back via
|
||||||
/// [mainHandler] since MethodChannel results must be delivered on the
|
/// [mainHandler] since MethodChannel results must be delivered on the
|
||||||
/// platform thread.
|
/// platform thread.
|
||||||
|
private fun removeAccountData(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val accountId = call.argument<String>("accountId")
|
||||||
|
if (accountId == null) {
|
||||||
|
result.error("bad_args", "Missing accountId", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val manager = WorkManager.getInstance(applicationContext)
|
||||||
|
manager.cancelAllWorkByTag("noo_sync_account_$accountId").result.get()
|
||||||
|
manager.cancelUniqueWork(SyncWorker.periodicNameFor(accountId)).result.get()
|
||||||
|
manager.cancelUniqueWork(SyncWorker.oneOffNameFor(accountId)).result.get()
|
||||||
|
manager.cancelUniqueWork(SyncWorker.UNIQUE_PERIODIC_NAME).result.get()
|
||||||
|
runBlocking {
|
||||||
|
SyncEngine.syncLock.withLock {
|
||||||
|
SyncEngine.removeAccountData(applicationContext, accountId)
|
||||||
|
SyncStatusBus.clearAccount(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainHandler.post { result.success(null) }
|
||||||
|
} catch (error: Exception) {
|
||||||
|
mainHandler.post { result.error("cleanup_failed", error.message, null) }
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
|
private fun removeLocalSync(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val accountId = call.argument<String>("accountId")
|
val accountId = call.argument<String>("accountId")
|
||||||
val path = call.argument<String>("path")
|
val path = call.argument<String>("path")
|
||||||
|
|||||||
@@ -509,6 +509,16 @@ object SyncEngine {
|
|||||||
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
|
* (see [diffFolder]'s `!localExists && !serverChanged` branch) instead
|
||||||
* of pulling it back down.
|
* of pulling it back down.
|
||||||
*/
|
*/
|
||||||
|
fun removeAccountData(context: Context, accountId: String) {
|
||||||
|
val root = syncRoot(context, accountId)
|
||||||
|
check(!root.exists() || root.deleteRecursively()) { "Could not remove synced files" }
|
||||||
|
check(context.getSharedPreferences(STATE_PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.remove(stateKey(accountId)).remove(rootsKey(accountId))
|
||||||
|
.remove(rootsVersionKey(accountId)).remove(missingKey(accountId)).commit()) {
|
||||||
|
"Could not remove sync records"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun removeLocalSync(context: Context, accountId: String, path: String) {
|
fun removeLocalSync(context: Context, accountId: String, path: String) {
|
||||||
val root = syncRoot(context, accountId)
|
val root = syncRoot(context, accountId)
|
||||||
val state = loadState(context, accountId).toMutableMap()
|
val state = loadState(context, accountId).toMutableMap()
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ object SyncStatusBus {
|
|||||||
publish()
|
publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun clearAccount(accountId: String) {
|
||||||
|
current = current.copy(
|
||||||
|
syncing = if (current.accountId == accountId) false else current.syncing,
|
||||||
|
syncingFileIds = if (current.accountId == accountId) emptySet() else current.syncingFileIds,
|
||||||
|
conflicts = current.conflicts.filter { it.accountId != accountId },
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
private fun publish() {
|
private fun publish() {
|
||||||
listeners.toList().forEach { it(current) }
|
listeners.toList().forEach { it(current) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -607,6 +607,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
DEVELOPMENT_TEAM = Q3JLTAG9PV;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -680,6 +681,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
DEVELOPMENT_TEAM = Q3JLTAG9PV;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -708,6 +710,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
DEVELOPMENT_TEAM = Q3JLTAG9PV;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -736,6 +739,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||||
|
DEVELOPMENT_TEAM = Q3JLTAG9PV;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -870,6 +874,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
DEVELOPMENT_TEAM = Q3JLTAG9PV;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -893,6 +898,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
DEVELOPMENT_TEAM = Q3JLTAG9PV;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
PRODUCT_BUNDLE_IDENTIFIER = dev.ayushya.noo;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
|||||||
@@ -149,6 +149,18 @@ enum NativeServices {
|
|||||||
// Fire and forget, like Android: the status stream reports the result.
|
// Fire and forget, like Android: the status stream reports the result.
|
||||||
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
|
Task { _ = await coordinator.resolveConflict(conflict, resolution: resolution, creds: creds) }
|
||||||
}
|
}
|
||||||
|
case "removeAccountData":
|
||||||
|
guard let accountId = args["accountId"] as? String else {
|
||||||
|
return result(FlutterError(code: "bad_args", message: "Missing accountId", details: nil))
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await coordinator.removeAccountData(accountId: accountId)
|
||||||
|
await MainActor.run { result(nil) }
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { result(FlutterError(code: "cleanup_failed", message: error.localizedDescription, details: nil)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
case "removeLocalSync":
|
case "removeLocalSync":
|
||||||
guard let accountId = args["accountId"] as? String, let path = args["path"] as? String else {
|
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))
|
return result(FlutterError(code: "bad_args", message: "Missing required arguments", details: nil))
|
||||||
|
|||||||
@@ -194,6 +194,16 @@ final class SyncCoordinator {
|
|||||||
/// removal under the same mutex as runs - a run still holding the old
|
/// 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
|
/// 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).
|
/// finishing after it would leave a file the state doesn't know about).
|
||||||
|
func removeAccountData(accountId: String) async throws {
|
||||||
|
cancel(accountId: accountId, forget: true)
|
||||||
|
try await mutex.withLock {
|
||||||
|
try store.removeAccountData(accountId: accountId)
|
||||||
|
for conflict in bus.snapshot().conflicts where conflict.accountId == accountId {
|
||||||
|
bus.removeConflict(accountId: accountId, fileId: conflict.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func removeLocalSync(accountId: String, path: String) async {
|
func removeLocalSync(accountId: String, path: String) async {
|
||||||
cancelRun(accountId: accountId, invalidateQueued: true)
|
cancelRun(accountId: accountId, invalidateQueued: true)
|
||||||
_ = try? await mutex.withLock {
|
_ = try? await mutex.withLock {
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ final class SyncStore {
|
|||||||
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
|
if changed { _ = write(Array(current).sorted(), to: file(accountId, "missing.json")) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeAccountData(accountId: String) throws {
|
||||||
|
for directory in ["sync", "sync-state"] {
|
||||||
|
let url = base.appendingPathComponent(directory).appendingPathComponent(accountId)
|
||||||
|
if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Removing a path's mirror
|
// MARK: - Removing a path's mirror
|
||||||
|
|
||||||
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
|
/// Deletes [path]'s local mirror (a file, or a whole folder's worth) and
|
||||||
|
|||||||
@@ -237,6 +237,21 @@ final class SyncEngineTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - The happy path
|
// MARK: - The happy path
|
||||||
|
|
||||||
|
func testRemovingAccountDeletesMirrorAndStateButPreservesOtherAccounts() async throws {
|
||||||
|
seedServer()
|
||||||
|
_ = await runner.run(config, force: false)
|
||||||
|
let other = store.syncRoot(accountId: "other").appendingPathComponent("keep.txt")
|
||||||
|
try Data("keep".utf8).write(to: other)
|
||||||
|
let mirrorPath = store.base.appendingPathComponent("sync/acct")
|
||||||
|
let statePath = store.base.appendingPathComponent("sync-state/acct")
|
||||||
|
try store.removeAccountData(accountId: "acct")
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: mirrorPath.path))
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: statePath.path))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: other.path))
|
||||||
|
XCTAssertTrue(try store.loadState(accountId: "acct").isEmpty)
|
||||||
|
try store.removeAccountData(accountId: "acct")
|
||||||
|
}
|
||||||
|
|
||||||
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
|
func testFirstSyncDownloadsEverythingAndMirrorsFolders() async {
|
||||||
seedServer()
|
seedServer()
|
||||||
FakeDav.mkdir("/Docs/empty")
|
FakeDav.mkdir("/Docs/empty")
|
||||||
|
|||||||
+40
-16
@@ -35,8 +35,8 @@ import 'widgets/app_top_bar.dart';
|
|||||||
import 'widgets/avatar_menu.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_button.dart';
|
import 'widgets/noo/core/noo_button.dart';
|
||||||
|
import 'widgets/noo/core/noo_pointer_selection.dart';
|
||||||
import 'widgets/noo/core/noo_fab.dart';
|
import 'widgets/noo/core/noo_fab.dart';
|
||||||
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||||
@@ -44,6 +44,7 @@ import 'widgets/noo/nav/noo_toolbar.dart';
|
|||||||
import 'widgets/noo/noo_layout.dart';
|
import 'widgets/noo/noo_layout.dart';
|
||||||
import 'widgets/noo/overlays/noo_dialog.dart';
|
import 'widgets/noo/overlays/noo_dialog.dart';
|
||||||
import 'widgets/shell/shell_common.dart';
|
import 'widgets/shell/shell_common.dart';
|
||||||
|
import 'widgets/shell/tablet_account_menu.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -135,6 +136,7 @@ class NextcloudApp extends StatelessWidget {
|
|||||||
return DynamicColorBuilder(
|
return DynamicColorBuilder(
|
||||||
builder: (lightDynamic, darkDynamic) {
|
builder: (lightDynamic, darkDynamic) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
builder: (context, child) => NooPointerSelection(child: child!),
|
||||||
title: 'Noo',
|
title: 'Noo',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(
|
theme: AppTheme.light(
|
||||||
@@ -209,7 +211,13 @@ class MainShellView extends StatefulWidget {
|
|||||||
State<MainShellView> createState() => _MainShellViewState();
|
State<MainShellView> createState() => _MainShellViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainShellViewState extends State<MainShellView> {
|
class _MainShellViewState extends State<MainShellView>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _paneTransition = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 240),
|
||||||
|
value: 1,
|
||||||
|
);
|
||||||
late AppTab _currentTab;
|
late AppTab _currentTab;
|
||||||
final Set<AppTab> _selectionActiveTabs = {};
|
final Set<AppTab> _selectionActiveTabs = {};
|
||||||
late final Map<AppTab, ScrollController> _scrollControllers;
|
late final Map<AppTab, ScrollController> _scrollControllers;
|
||||||
@@ -329,6 +337,7 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
_shareSub?.cancel();
|
_shareSub?.cancel();
|
||||||
_pickSub?.cancel();
|
_pickSub?.cancel();
|
||||||
_shareAccountSync?.dispose();
|
_shareAccountSync?.dispose();
|
||||||
|
_paneTransition.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +347,6 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
final pick = context.watch<PickController>();
|
final pick = context.watch<PickController>();
|
||||||
final connectivity = context.watch<ConnectivityController>();
|
final connectivity = context.watch<ConnectivityController>();
|
||||||
final session = context.watch<SessionController>();
|
|
||||||
final quota = context.watch<FilesController>().quota;
|
final quota = context.watch<FilesController>().quota;
|
||||||
// Cheap and already loaded once the Trash tab has been visited this
|
// Cheap and already loaded once the Trash tab has been visited this
|
||||||
// session - not worth adding a fetch just to populate a nav badge.
|
// session - not worth adding a fetch just to populate a nav badge.
|
||||||
@@ -397,6 +405,10 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
|
|
||||||
void selectTab(AppTab tab) {
|
void selectTab(AppTab tab) {
|
||||||
if (tab == _currentTab) return;
|
if (tab == _currentTab) return;
|
||||||
|
if (NooLayout.isDesktop(context) &&
|
||||||
|
!MediaQuery.disableAnimationsOf(context)) {
|
||||||
|
_paneTransition.forward(from: 0);
|
||||||
|
}
|
||||||
setState(() => _currentTab = tab);
|
setState(() => _currentTab = tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,16 +498,9 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
NooSidebar(
|
NooSidebar(
|
||||||
account: NooSidebarAccount(
|
account: const TabletAccountMenu(),
|
||||||
avatar: NooAvatar(
|
search: const ShellSearchLauncher(onSurface: true),
|
||||||
initials: accountInitial(session.username),
|
groupItems: true,
|
||||||
current: true,
|
|
||||||
size: 32,
|
|
||||||
),
|
|
||||||
name: session.username,
|
|
||||||
subtitle: serverHost(session.serverUrl),
|
|
||||||
onTap: () => showAccountSwitcher(context),
|
|
||||||
),
|
|
||||||
items: [
|
items: [
|
||||||
for (final tab in pinnedTabs)
|
for (final tab in pinnedTabs)
|
||||||
NooSidebarItem(
|
NooSidebarItem(
|
||||||
@@ -529,18 +534,37 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
children: [
|
children: [
|
||||||
NooToolbar(
|
NooToolbar(
|
||||||
title: selectedTab.label,
|
title: selectedTab.label,
|
||||||
search: const ShellSearchLauncher(onSurface: true),
|
backgroundColor: colors.bg,
|
||||||
|
framed: false,
|
||||||
actions: [
|
actions: [
|
||||||
if (canUpload)
|
if (canUpload)
|
||||||
NooButton(
|
NooButton(
|
||||||
icon: LucideIcons.upload,
|
icon: LucideIcons.plus,
|
||||||
onTap: () => showCreateMenu(context),
|
onTap: () => showCreateMenu(context),
|
||||||
child: const Text('Upload'),
|
child: const Text('Upload'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
child: ColoredBox(
|
||||||
|
color: colors.surface2,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: Tween<double>(begin: .65, end: 1)
|
||||||
|
.animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _paneTransition,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SizedBox.expand(child: tabStack),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ class SavedAccount {
|
|||||||
final String id;
|
final String id;
|
||||||
final String serverUrl;
|
final String serverUrl;
|
||||||
final String username;
|
final String username;
|
||||||
|
final String? displayName;
|
||||||
|
|
||||||
|
String get label =>
|
||||||
|
displayName?.trim().isNotEmpty == true ? displayName!.trim() : username;
|
||||||
|
|
||||||
const SavedAccount({
|
const SavedAccount({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.serverUrl,
|
required this.serverUrl,
|
||||||
required this.username,
|
required this.username,
|
||||||
|
this.displayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// A deterministic id from the server + username, so re-adding the same
|
/// A deterministic id from the server + username, so re-adding the same
|
||||||
@@ -25,11 +30,13 @@ class SavedAccount {
|
|||||||
'id': id,
|
'id': id,
|
||||||
'serverUrl': serverUrl,
|
'serverUrl': serverUrl,
|
||||||
'username': username,
|
'username': username,
|
||||||
|
if (displayName != null) 'displayName': displayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory SavedAccount.fromJson(Map<String, dynamic> json) => SavedAccount(
|
factory SavedAccount.fromJson(Map<String, dynamic> json) => SavedAccount(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
serverUrl: json['serverUrl'] as String,
|
serverUrl: json['serverUrl'] as String,
|
||||||
username: json['username'] as String,
|
username: json['username'] as String,
|
||||||
|
displayName: json['displayName'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -470,7 +470,10 @@ class FilesController extends ChangeNotifier
|
|||||||
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
|
'[FilesController] Loaded ${_items.length} items for $_currentFolderPath',
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
_quota = await service.fetchUserQuota();
|
final quota = await service.fetchUserQuota();
|
||||||
|
if (gen != session.sessionGeneration) return;
|
||||||
|
_quota = quota;
|
||||||
|
await session.cacheDisplayName(quota.userName, generation: gen);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[FilesController] Quota fetch warning: $e');
|
debugPrint('[FilesController] Quota fetch warning: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,39 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
// Getters
|
// Getters
|
||||||
String get serverUrl => _serverUrl;
|
String get serverUrl => _serverUrl;
|
||||||
String get username => _username;
|
String get username => _username;
|
||||||
|
String get displayName =>
|
||||||
|
_accounts
|
||||||
|
.where((a) => a.id == activeAccountId)
|
||||||
|
.map((a) => a.label)
|
||||||
|
.firstOrNull ??
|
||||||
|
_username;
|
||||||
|
|
||||||
|
Future<void> cacheDisplayName(String name, {required int generation}) async {
|
||||||
|
final clean = name.trim();
|
||||||
|
if (generation != _sessionGeneration || clean.isEmpty) return;
|
||||||
|
final id = activeAccountId;
|
||||||
|
if (id == null ||
|
||||||
|
!_accounts.any((a) => a.id == id && a.displayName != clean)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final prefs = await prefsFuture;
|
||||||
|
if (generation != _sessionGeneration) return;
|
||||||
|
_accounts = [
|
||||||
|
for (final a in _accounts)
|
||||||
|
if (a.id == id)
|
||||||
|
SavedAccount(
|
||||||
|
id: a.id,
|
||||||
|
serverUrl: a.serverUrl,
|
||||||
|
username: a.username,
|
||||||
|
displayName: clean,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
a,
|
||||||
|
];
|
||||||
|
await accountStore.saveAccounts(prefs, _accounts);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
bool get isLoggedIn => _isLoggedIn;
|
bool get isLoggedIn => _isLoggedIn;
|
||||||
bool get isLoading => _isLoading;
|
bool get isLoading => _isLoading;
|
||||||
bool get isRestoringSession => _isRestoringSession;
|
bool get isRestoringSession => _isRestoringSession;
|
||||||
@@ -560,6 +593,8 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (index == -1) return;
|
if (index == -1) return;
|
||||||
final wasActive = accountId == _activeAccountId;
|
final wasActive = accountId == _activeAccountId;
|
||||||
|
|
||||||
|
await SyncService.removeAccountData(accountId);
|
||||||
|
|
||||||
_accounts = [..._accounts]..removeAt(index);
|
_accounts = [..._accounts]..removeAt(index);
|
||||||
final prefs = await prefsFuture;
|
final prefs = await prefsFuture;
|
||||||
await accountStore.saveAccounts(prefs, _accounts);
|
await accountStore.saveAccounts(prefs, _accounts);
|
||||||
@@ -568,8 +603,6 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await prefs.remove(accountStore.accountPrefKey(accountId, key));
|
await prefs.remove(accountStore.accountPrefKey(accountId, key));
|
||||||
}
|
}
|
||||||
await accountStore.setSignedOut(prefs, accountId, false);
|
await accountStore.setSignedOut(prefs, accountId, false);
|
||||||
// Its background sync job holds that account's credentials - stop it.
|
|
||||||
unawaited(SyncService.cancelAccount(accountId));
|
|
||||||
|
|
||||||
if (!wasActive) {
|
if (!wasActive) {
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -7,7 +7,24 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
|||||||
|
|
||||||
/// What swiping a Files list-view item left/right does, user-configurable
|
/// What swiping a Files list-view item left/right does, user-configurable
|
||||||
/// in Settings.
|
/// in Settings.
|
||||||
enum SwipeAction { none, favorite, delete, share }
|
enum SwipeAction {
|
||||||
|
none(null),
|
||||||
|
favorite(SelectionActionKind.favorite),
|
||||||
|
delete(SelectionActionKind.delete),
|
||||||
|
share(SelectionActionKind.share),
|
||||||
|
download(SelectionActionKind.download),
|
||||||
|
copy(SelectionActionKind.copy),
|
||||||
|
move(SelectionActionKind.move),
|
||||||
|
rename(SelectionActionKind.rename),
|
||||||
|
sync(SelectionActionKind.sync),
|
||||||
|
details(SelectionActionKind.details);
|
||||||
|
|
||||||
|
const SwipeAction(this.selectionKind);
|
||||||
|
|
||||||
|
final SelectionActionKind? selectionKind;
|
||||||
|
|
||||||
|
String get label => selectionKind?.label ?? 'None';
|
||||||
|
}
|
||||||
|
|
||||||
/// How the mobile Upload button 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]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class ShareAccountEntry {
|
|||||||
final String id;
|
final String id;
|
||||||
final String serverUrl;
|
final String serverUrl;
|
||||||
final String username;
|
final String username;
|
||||||
|
final String? displayName;
|
||||||
final String password;
|
final String password;
|
||||||
|
|
||||||
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
|
/// The account's Files "hidden files" filter: `hide`, `only` or `include`.
|
||||||
@@ -24,6 +25,7 @@ class ShareAccountEntry {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.serverUrl,
|
required this.serverUrl,
|
||||||
required this.username,
|
required this.username,
|
||||||
|
this.displayName,
|
||||||
required this.password,
|
required this.password,
|
||||||
required this.hiddenFilter,
|
required this.hiddenFilter,
|
||||||
required this.storageScope,
|
required this.storageScope,
|
||||||
@@ -60,7 +62,7 @@ class ShareAccountService {
|
|||||||
'username': a.username,
|
'username': a.username,
|
||||||
'authHeader': basicAuth(a.username, a.password),
|
'authHeader': basicAuth(a.username, a.password),
|
||||||
'displayName':
|
'displayName':
|
||||||
'${a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
'${a.displayName?.trim().isNotEmpty == true ? a.displayName!.trim() : a.username}@${Uri.tryParse(a.serverUrl)?.host ?? a.serverUrl}',
|
||||||
'hiddenFilter': a.hiddenFilter,
|
'hiddenFilter': a.hiddenFilter,
|
||||||
'storageScope': a.storageScope,
|
'storageScope': a.storageScope,
|
||||||
},
|
},
|
||||||
@@ -119,6 +121,7 @@ class ShareAccountService {
|
|||||||
id: account.id,
|
id: account.id,
|
||||||
serverUrl: account.serverUrl,
|
serverUrl: account.serverUrl,
|
||||||
username: account.username,
|
username: account.username,
|
||||||
|
displayName: account.label,
|
||||||
password: password,
|
password: password,
|
||||||
hiddenFilter: filter.name,
|
hiddenFilter: filter.name,
|
||||||
storageScope: scope.name,
|
storageScope: scope.name,
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deletes the removed account's mirror and native sync state after stopping
|
||||||
|
/// its runs. Await before forgetting the account so cleanup failures can retry.
|
||||||
|
static Future<void> removeAccountData(String accountId) async {
|
||||||
|
await invokeIfAvailable(_channel, 'removeAccountData', {
|
||||||
|
'accountId': accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static String _basicAuth(SavedAccount account, String password) =>
|
static String _basicAuth(SavedAccount account, String password) =>
|
||||||
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
|
'Basic ${base64Encode(utf8.encode('${account.username}:$password'))}';
|
||||||
|
|
||||||
@@ -143,7 +151,10 @@ class SyncService {
|
|||||||
/// background job is being turned off - the account's paths are still
|
/// background job is being turned off - the account's paths are still
|
||||||
/// synced by hand ("Sync now"), and a conflict notification's actions
|
/// synced by hand ("Sync now"), and a conflict notification's actions
|
||||||
/// still need its credentials.
|
/// still need its credentials.
|
||||||
static Future<void> cancelAccount(String accountId, {bool forget = true}) async {
|
static Future<void> cancelAccount(
|
||||||
|
String accountId, {
|
||||||
|
bool forget = true,
|
||||||
|
}) async {
|
||||||
await invokeIfAvailable(_channel, 'cancel', {
|
await invokeIfAvailable(_channel, 'cancel', {
|
||||||
'accountId': accountId,
|
'accountId': accountId,
|
||||||
'forget': forget,
|
'forget': forget,
|
||||||
|
|||||||
+120
-33
@@ -5,9 +5,11 @@ import '../widgets/noo/lists/noo_grouped_list.dart';
|
|||||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||||
import '../widgets/noo/nav/noo_top_bar.dart';
|
import '../widgets/noo/nav/noo_top_bar.dart';
|
||||||
import '../widgets/noo/nav/noo_toolbar.dart';
|
import '../widgets/noo/nav/noo_toolbar.dart';
|
||||||
|
import '../widgets/noo/nav/noo_sidebar.dart';
|
||||||
import '../widgets/noo/noo_layout.dart';
|
import '../widgets/noo/noo_layout.dart';
|
||||||
import '../widgets/settings/settings_account_card.dart';
|
import '../widgets/settings/settings_account_card.dart';
|
||||||
import '../widgets/settings/settings_accounts.dart';
|
import '../widgets/settings/settings_accounts.dart';
|
||||||
|
import '../widgets/settings/settings_about.dart';
|
||||||
import '../widgets/settings/settings_action_bar.dart';
|
import '../widgets/settings/settings_action_bar.dart';
|
||||||
import '../widgets/settings/settings_appearance.dart';
|
import '../widgets/settings/settings_appearance.dart';
|
||||||
import '../widgets/settings/settings_file_sync.dart';
|
import '../widgets/settings/settings_file_sync.dart';
|
||||||
@@ -15,35 +17,20 @@ import '../widgets/settings/settings_files_cache.dart';
|
|||||||
import '../widgets/settings/settings_security.dart';
|
import '../widgets/settings/settings_security.dart';
|
||||||
import '../widgets/settings/settings_swipe.dart';
|
import '../widgets/settings/settings_swipe.dart';
|
||||||
import '../widgets/settings/settings_tabs.dart';
|
import '../widgets/settings/settings_tabs.dart';
|
||||||
|
import '../widgets/settings/settings_section.dart';
|
||||||
|
|
||||||
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
|
/// Settings uses pushed category screens on phones and an inline category
|
||||||
/// order: account card, accounts, security, file sync, files cache,
|
/// sidebar with a rounded content pane on tablets.
|
||||||
/// appearance, tabs, action bar, swipe on a file).
|
class AccountView extends StatefulWidget {
|
||||||
///
|
|
||||||
/// Every layout (including wide tablet, in a centered 640px column) is
|
|
||||||
/// a two-level menu, the way native iOS/Android Settings apps
|
|
||||||
/// work: [SettingsAccountCard] (the account summary, not a settings picker)
|
|
||||||
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and
|
|
||||||
/// every other section becomes one tappable [NooSettingsRow] - icon, title,
|
|
||||||
/// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new
|
|
||||||
/// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack],
|
|
||||||
/// containing just that section's content full-screen. This replaces an
|
|
||||||
/// earlier design where every section rendered inline in one long
|
|
||||||
/// collapsible-sections column (`_MobileList`, since removed) - and before
|
|
||||||
/// that, a trailing jump rail (an even earlier version) that scrolled that
|
|
||||||
/// *same* page to an anchor. Both were rejected: the jump rail added a
|
|
||||||
/// second, redundant way to navigate on top of plain scrolling without
|
|
||||||
/// shortening the page, and the collapsible-sections column still left a
|
|
||||||
/// long page to scroll past even collapsed. A genuinely separate pushed
|
|
||||||
/// screen per category removes the scroll-depth problem outright, so
|
|
||||||
/// neither a jump rail nor per-section collapsing is needed any more - see
|
|
||||||
/// [SettingsSection]'s doc comment for how that reflects in its mobile
|
|
||||||
/// layout. See each `widgets/settings/*.dart` file for a section's own
|
|
||||||
/// content and any setting that had to be slotted in or grouped under
|
|
||||||
/// "Advanced appearance".
|
|
||||||
class AccountView extends StatelessWidget {
|
|
||||||
const AccountView({super.key});
|
const AccountView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountView> createState() => _AccountViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountViewState extends State<AccountView> {
|
||||||
|
int selected = 0;
|
||||||
|
|
||||||
static final _categories = <_SettingsCategory>[
|
static final _categories = <_SettingsCategory>[
|
||||||
_SettingsCategory(
|
_SettingsCategory(
|
||||||
title: 'Accounts',
|
title: 'Accounts',
|
||||||
@@ -85,6 +72,11 @@ class AccountView extends StatelessWidget {
|
|||||||
icon: LucideIcons.chevronsLeftRight,
|
icon: LucideIcons.chevronsLeftRight,
|
||||||
builder: (_) => const SettingsSwipeSection(),
|
builder: (_) => const SettingsSwipeSection(),
|
||||||
),
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'About',
|
||||||
|
icon: LucideIcons.info,
|
||||||
|
builder: (_) => const SettingsAboutSection(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -92,6 +84,101 @@ class AccountView extends StatelessWidget {
|
|||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final desktop = NooLayout.isDesktop(context);
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
|
if (desktop) {
|
||||||
|
final category = _categories[selected];
|
||||||
|
final duration = MediaQuery.disableAnimationsOf(context)
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 260);
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
NooSidebar(
|
||||||
|
windowControls: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
|
tooltip: 'Back',
|
||||||
|
onPressed: () => Navigator.maybePop(context),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Settings',
|
||||||
|
style: NooText.title.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
account: SettingsAccountCard(
|
||||||
|
compact: true,
|
||||||
|
onManage: () => setState(() => selected = 0),
|
||||||
|
),
|
||||||
|
groupItems: true,
|
||||||
|
items: [
|
||||||
|
for (var index = 0; index < _categories.length; index++)
|
||||||
|
if (_categories[index].title != 'Swipe on a file')
|
||||||
|
NooSidebarItem(
|
||||||
|
icon: _categories[index].icon,
|
||||||
|
label: _categories[index].title,
|
||||||
|
selected: selected == index,
|
||||||
|
onTap: () => setState(() => selected = index),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
NooToolbar(
|
||||||
|
title: category.title,
|
||||||
|
backgroundColor: colors.bg,
|
||||||
|
framed: false,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 16),
|
||||||
|
child: Material(
|
||||||
|
color: colors.surface2,
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: duration,
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (child, animation) =>
|
||||||
|
FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween(
|
||||||
|
begin: const Offset(0, .025),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
key: ValueKey(category.title),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
children: [
|
||||||
|
SettingsCategoryHeading(
|
||||||
|
title: category.title,
|
||||||
|
child: category.builder(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
appBar: desktop
|
appBar: desktop
|
||||||
@@ -184,12 +271,7 @@ class _MobileMenu extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A pushed, single-category Settings screen: [NooTopBar] titled with the
|
/// A pushed category screen used by phone navigation.
|
||||||
/// category, a back button, and just that section's own content - the
|
|
||||||
/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always
|
|
||||||
/// built in a mobile-width context (desktop never opens this screen; it
|
|
||||||
/// shows every section inline in its own grid instead), so the section
|
|
||||||
/// widgets inside render their normal mobile layout unchanged.
|
|
||||||
class _SettingsCategoryScreen extends StatelessWidget {
|
class _SettingsCategoryScreen extends StatelessWidget {
|
||||||
final _SettingsCategory category;
|
final _SettingsCategory category;
|
||||||
const _SettingsCategoryScreen({required this.category});
|
const _SettingsCategoryScreen({required this.category});
|
||||||
@@ -223,7 +305,12 @@ class _SettingsCategoryScreen extends StatelessWidget {
|
|||||||
NooSpace.xxl,
|
NooSpace.xxl,
|
||||||
),
|
),
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
children: [category.builder(context)],
|
children: [
|
||||||
|
SettingsCategoryHeading(
|
||||||
|
title: category.title,
|
||||||
|
child: category.builder(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ class ActivityView extends StatelessWidget {
|
|||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final activities = files.activities;
|
final activities = files.activities;
|
||||||
final isDesktop = NooLayout.isDesktop(context);
|
|
||||||
final groups = groupByCalendarDay<NextcloudActivity>(
|
final groups = groupByCalendarDay<NextcloudActivity>(
|
||||||
activities,
|
activities,
|
||||||
(a) => a.timestamp,
|
(a) => a.timestamp,
|
||||||
@@ -116,23 +115,13 @@ class ActivityView extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: () => context.read<FilesController>().refreshData(),
|
onRefresh: () => context.read<FilesController>().refreshData(),
|
||||||
// "Desktop limits it to 760px wide" (DESIGN_SYSTEM.md §4) - centered
|
|
||||||
// rather than left-aligned, so a wide window doesn't stretch the
|
|
||||||
// feed's short sentences edge to edge.
|
|
||||||
child: isDesktop
|
|
||||||
? Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
|
||||||
child: scrollView,
|
child: scrollView,
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: scrollView,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
favorite: item.isFavorite,
|
favorite: item.isFavorite,
|
||||||
iosStyle: NooLayout.iosStyle(context),
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle: () => _toggleSelection(item),
|
||||||
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
|
thumbnail: _thumbnailFor(context, item, extent: NooSizes.rowMobile),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_isSelecting) {
|
if (_isSelecting) {
|
||||||
@@ -434,6 +435,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
col3: item.isFolder ? null : formatBytes(item.size),
|
col3: item.isFolder ? null : formatBytes(item.size),
|
||||||
favorite: item.isFavorite,
|
favorite: item.isFavorite,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle: () => _toggleSelection(item),
|
||||||
thumbnail: _thumbnailFor(
|
thumbnail: _thumbnailFor(
|
||||||
context,
|
context,
|
||||||
item,
|
item,
|
||||||
@@ -477,6 +479,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
thumbnailHeight: thumbnailHeight,
|
thumbnailHeight: thumbnailHeight,
|
||||||
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
|
thumbnail: _thumbnailFor(context, item, extent: thumbnailHeight),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle: () => _toggleSelection(item),
|
||||||
verticalOverflowIcon: !NooLayout.iosStyle(context),
|
verticalOverflowIcon: !NooLayout.iosStyle(context),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (_isSelecting) {
|
if (_isSelecting) {
|
||||||
@@ -600,14 +603,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
vertical: NooSpace.xs,
|
vertical: NooSpace.xs,
|
||||||
),
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||||
crossAxisCount: isDesktop
|
|
||||||
? 5
|
|
||||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
|
||||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
|
||||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
|
||||||
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
return _buildGridCard(
|
return _buildGridCard(
|
||||||
context,
|
context,
|
||||||
@@ -680,7 +676,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
? _buildSelectionBar(context, selectedItems)
|
? _buildSelectionBar(context, selectedItems)
|
||||||
: null,
|
: null,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
|
|||||||
@@ -729,9 +729,7 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
default:
|
default:
|
||||||
if (_isPdf) {
|
if (_isPdf) {
|
||||||
return MediaPdfPreview(
|
return MediaPdfPreview(
|
||||||
topInset: _controlsVisible
|
topInset: MediaQuery.paddingOf(context).top + 60,
|
||||||
? MediaQuery.paddingOf(context).top + 60
|
|
||||||
: 0,
|
|
||||||
item: widget.item,
|
item: widget.item,
|
||||||
session: session,
|
session: session,
|
||||||
localPathResolver: widget.localPathResolver,
|
localPathResolver: widget.localPathResolver,
|
||||||
|
|||||||
+35
-36
@@ -629,30 +629,23 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The user's configured swipe action for one side of a row, or null if
|
/// Reuse the single-item action handlers so swipe gestures and the action
|
||||||
/// that side is off or set to an action the design's two-slot
|
/// bar agree on labels, sheets and mutations. Delete retains its swipe
|
||||||
/// `NooSwipeAction` has no room for (`SwipeAction.share` - still reachable
|
/// confirmation before making any server changes.
|
||||||
/// via the row's overflow menu / the "Share" bulk action).
|
NooSwipeActionSpec? _swipeSpec(SwipeAction action, NextcloudItem item) {
|
||||||
NooSwipeActionSpec? _swipeSpec(
|
final kind = action.selectionKind;
|
||||||
SwipeAction action,
|
if (kind == null) return null;
|
||||||
NextcloudItem item,
|
final selectedAction = _buildSelectionActions(context, [
|
||||||
ItemOperations ops,
|
item,
|
||||||
) {
|
]).firstWhere((candidate) => candidate.kind == kind);
|
||||||
switch (action) {
|
|
||||||
case SwipeAction.favorite:
|
|
||||||
return NooSwipeActionSpec(
|
return NooSwipeActionSpec(
|
||||||
kind: NooSwipeActionKind.favorite,
|
kind: kind,
|
||||||
onTriggered: () => ops.toggleItemFavorite(item),
|
label: selectedAction.label,
|
||||||
|
icon: selectedAction.icon,
|
||||||
|
onTriggered: kind == SelectionActionKind.delete
|
||||||
|
? () => _confirmAndDeleteViaSwipe(item)
|
||||||
|
: selectedAction.onTap,
|
||||||
);
|
);
|
||||||
case SwipeAction.delete:
|
|
||||||
return NooSwipeActionSpec(
|
|
||||||
kind: NooSwipeActionKind.delete,
|
|
||||||
onTriggered: () => _confirmAndDeleteViaSwipe(item),
|
|
||||||
);
|
|
||||||
case SwipeAction.share:
|
|
||||||
case SwipeAction.none:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -752,14 +745,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
NooSpace.lg,
|
NooSpace.lg,
|
||||||
),
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||||
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) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
return _FolderEnterAnimation(
|
return _FolderEnterAnimation(
|
||||||
@@ -978,7 +964,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
? _buildSelectionBar(context, selectedItems)
|
? _buildSelectionBar(context, selectedItems)
|
||||||
: null,
|
: null,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
@@ -1068,14 +1054,16 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
tone: hasConflicts
|
tone: hasConflicts
|
||||||
? NooSummaryCardTone.danger
|
? NooSummaryCardTone.danger
|
||||||
: NooSummaryCardTone.normal,
|
: NooSummaryCardTone.normal,
|
||||||
action: NooButton(
|
action: sync.syncEverything || sync.syncedPaths.isNotEmpty
|
||||||
|
? NooButton(
|
||||||
variant: NooButtonVariant.tonal,
|
variant: NooButtonVariant.tonal,
|
||||||
size: NooButtonSize.compact,
|
size: NooButtonSize.compact,
|
||||||
icon: LucideIcons.refreshCw,
|
icon: LucideIcons.refreshCw,
|
||||||
disabled: sync.isSyncingNow,
|
disabled: sync.isSyncingNow,
|
||||||
onTap: () => sync.syncOnPull(),
|
onTap: () => sync.syncOnPull(),
|
||||||
child: const Text('Sync now'),
|
child: const Text('Sync now'),
|
||||||
),
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1343,7 +1331,6 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
final sync = context.watch<SyncStatusController>();
|
final sync = context.watch<SyncStatusController>();
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
final ops = context.read<ItemOperations>();
|
|
||||||
final isSelected = _selectedIds.contains(item.id);
|
final isSelected = _selectedIds.contains(item.id);
|
||||||
// Pick mode and selection are server-side features - never on Offline.
|
// Pick mode and selection are server-side features - never on Offline.
|
||||||
final picking = !_offline && pick.isPicking;
|
final picking = !_offline && pick.isPicking;
|
||||||
@@ -1368,6 +1355,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
favorite: item.isFavorite,
|
favorite: item.isFavorite,
|
||||||
iosStyle: NooLayout.iosStyle(context),
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle:
|
||||||
|
_offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
|
? null
|
||||||
|
: () => _toggleSelection(item),
|
||||||
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.row),
|
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.row),
|
||||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
@@ -1381,8 +1372,8 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
final swipeable = _isSelecting || picking || _offline
|
final swipeable = _isSelecting || picking || _offline
|
||||||
? row
|
? row
|
||||||
: NooSwipeAction(
|
: NooSwipeAction(
|
||||||
startAction: _swipeSpec(settings.swipeRightAction, item, ops),
|
startAction: _swipeSpec(settings.swipeRightAction, item),
|
||||||
endAction: _swipeSpec(settings.swipeLeftAction, item, ops),
|
endAction: _swipeSpec(settings.swipeLeftAction, item),
|
||||||
child: row,
|
child: row,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1455,6 +1446,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
statuses: _nooStatuses(status),
|
statuses: _nooStatuses(status),
|
||||||
favorite: item.isFavorite,
|
favorite: item.isFavorite,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle:
|
||||||
|
_offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
|
? null
|
||||||
|
: () => _toggleSelection(item),
|
||||||
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
|
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
|
||||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
@@ -1557,6 +1552,10 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
: null,
|
: null,
|
||||||
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
|
thumbnailHeight: NooLayout.isDesktop(context) ? 118 : 104,
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle:
|
||||||
|
_offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
|
? null
|
||||||
|
: () => _toggleSelection(item),
|
||||||
onTap: () => _handleItemTap(context, item, picking: picking),
|
onTap: () => _handleItemTap(context, item, picking: picking),
|
||||||
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
|
||||||
? null
|
? null
|
||||||
|
|||||||
+62
-19
@@ -37,7 +37,9 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.isAddingAccount) {
|
if (widget.isAddingAccount) {
|
||||||
_originalActiveAccountId = context.read<SessionController>().activeAccountId;
|
_originalActiveAccountId = context
|
||||||
|
.read<SessionController>()
|
||||||
|
.activeAccountId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +52,17 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
void _handleContinue() {
|
void _handleContinue() {
|
||||||
final url = _urlController.text.trim();
|
final url = _urlController.text.trim();
|
||||||
if (url.isEmpty) {
|
if (url.isEmpty) {
|
||||||
setState(() => _localError = 'Please enter your Nextcloud server address');
|
setState(
|
||||||
|
() => _localError = 'Please enter your Nextcloud server address',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _localError = null);
|
setState(() => _localError = null);
|
||||||
FocusScope.of(context).unfocus();
|
FocusScope.of(context).unfocus();
|
||||||
context.read<SessionController>().startLoginFlow(url, addAccount: widget.isAddingAccount);
|
context.read<SessionController>().startLoginFlow(
|
||||||
|
url,
|
||||||
|
addAccount: widget.isAddingAccount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
|
/// Resumes a saved account with one tap (see [SessionController.switchAccount]).
|
||||||
@@ -73,7 +80,7 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
..hideCurrentSnackBar()
|
..hideCurrentSnackBar()
|
||||||
..showSnackBar(
|
..showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text("Couldn't sign in as ${account.username}"),
|
content: Text("Couldn't sign in as ${account.label}"),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -97,7 +104,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
|
|
||||||
final isAwaitingBrowser = session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
final isAwaitingBrowser =
|
||||||
|
session.loginFlowStatus == LoginFlowStatus.awaitingBrowser;
|
||||||
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
|
final isInitiating = session.loginFlowStatus == LoginFlowStatus.initiating;
|
||||||
|
|
||||||
// A new/refreshed account has just become active - pop back to
|
// A new/refreshed account has just become active - pop back to
|
||||||
@@ -117,7 +125,9 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
// SessionController.startLoginFlow's doc comment for why. Push it the
|
// SessionController.startLoginFlow's doc comment for why. Push it the
|
||||||
// moment there's a URL to show; _webViewPushed resets once that route
|
// moment there's a URL to show; _webViewPushed resets once that route
|
||||||
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
// pops (cancelled or done) so a retry after cancelling pushes it again.
|
||||||
if (!_webViewPushed && isAwaitingBrowser && session.pendingLoginUrl != null) {
|
if (!_webViewPushed &&
|
||||||
|
isAwaitingBrowser &&
|
||||||
|
session.pendingLoginUrl != null) {
|
||||||
_webViewPushed = true;
|
_webViewPushed = true;
|
||||||
final url = session.pendingLoginUrl!;
|
final url = session.pendingLoginUrl!;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
@@ -142,7 +152,10 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final errorMessage =
|
final errorMessage =
|
||||||
_localError ?? (session.loginFlowStatus == LoginFlowStatus.error ? session.errorMessage : null);
|
_localError ??
|
||||||
|
(session.loginFlowStatus == LoginFlowStatus.error
|
||||||
|
? session.errorMessage
|
||||||
|
: null);
|
||||||
|
|
||||||
final body = Scaffold(
|
final body = Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
@@ -167,7 +180,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
// deleting it, specifically so it can be resumed
|
// deleting it, specifically so it can be resumed
|
||||||
// from here with one tap - no need to repeat
|
// from here with one tap - no need to repeat
|
||||||
// Login Flow v2.
|
// Login Flow v2.
|
||||||
if (!widget.isAddingAccount && session.accounts.isNotEmpty) ...[
|
if (!widget.isAddingAccount &&
|
||||||
|
session.accounts.isNotEmpty) ...[
|
||||||
_SavedAccountsSection(
|
_SavedAccountsSection(
|
||||||
accounts: session.accounts,
|
accounts: session.accounts,
|
||||||
onSelect: (account) => _continueAsAccount(account),
|
onSelect: (account) => _continueAsAccount(account),
|
||||||
@@ -179,7 +193,10 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
Expanded(child: Divider(color: colors.line)),
|
Expanded(child: Divider(color: colors.line)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Text('or', style: NooText.meta.copyWith(color: colors.fg3)),
|
child: Text(
|
||||||
|
'or',
|
||||||
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(child: Divider(color: colors.line)),
|
Expanded(child: Divider(color: colors.line)),
|
||||||
],
|
],
|
||||||
@@ -188,7 +205,8 @@ class _LoginViewState extends State<LoginView> {
|
|||||||
],
|
],
|
||||||
_ServerForm(
|
_ServerForm(
|
||||||
urlController: _urlController,
|
urlController: _urlController,
|
||||||
isLoading: isInitiating || session.isLoading || isAwaitingBrowser,
|
isLoading:
|
||||||
|
isInitiating || session.isLoading || isAwaitingBrowser,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
onContinue: _handleContinue,
|
onContinue: _handleContinue,
|
||||||
),
|
),
|
||||||
@@ -246,11 +264,19 @@ class _ServerForm extends StatelessWidget {
|
|||||||
// in-app, rather than the full-color launcher icon.
|
// in-app, rather than the full-color launcher icon.
|
||||||
child: ColorFiltered(
|
child: ColorFiltered(
|
||||||
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
colorFilter: ColorFilter.mode(colors.fg1, BlendMode.srcIn),
|
||||||
child: Image.asset('assets/icon/app_icon_monochrome.png', width: 80, height: 80),
|
child: Image.asset(
|
||||||
|
'assets/icon/app_icon_monochrome.png',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('Noo', textAlign: TextAlign.center, style: NooText.largeTitle.copyWith(color: colors.fg1)),
|
Text(
|
||||||
|
'Noo',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: NooText.largeTitle.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'Connect to your self-hosted server',
|
'Connect to your self-hosted server',
|
||||||
@@ -317,7 +343,11 @@ class _SavedAccountsSection extends StatelessWidget {
|
|||||||
final ValueChanged<SavedAccount> onSelect;
|
final ValueChanged<SavedAccount> onSelect;
|
||||||
final ValueChanged<SavedAccount> onRemove;
|
final ValueChanged<SavedAccount> onRemove;
|
||||||
|
|
||||||
const _SavedAccountsSection({required this.accounts, required this.onSelect, required this.onRemove});
|
const _SavedAccountsSection({
|
||||||
|
required this.accounts,
|
||||||
|
required this.onSelect,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -328,7 +358,10 @@ class _SavedAccountsSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
child: Text('Continue as', style: NooText.label.copyWith(color: colors.fg2)),
|
child: Text(
|
||||||
|
'Continue as',
|
||||||
|
style: NooText.label.copyWith(color: colors.fg2),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
@@ -358,7 +391,11 @@ class _SavedAccountRow extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _SavedAccountRow({required this.account, required this.onTap, required this.onRemove});
|
const _SavedAccountRow({
|
||||||
|
required this.account,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -369,18 +406,21 @@ class _SavedAccountRow extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
NooAvatar(initials: accountInitial(account.username), size: 36),
|
NooAvatar(initials: accountInitial(account.label), size: 36),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
spacing: 2,
|
spacing: 2,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
account.username,
|
account.label,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: NooText.bodyL.copyWith(
|
style: NooText.bodyL.copyWith(
|
||||||
@@ -404,7 +444,10 @@ class _SavedAccountRow extends StatelessWidget {
|
|||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surface2,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
|
child: Icon(LucideIcons.x, size: 16, color: colors.fg2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -283,14 +283,7 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||||
crossAxisCount: desktop
|
|
||||||
? 5
|
|
||||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
|
||||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
|
||||||
crossAxisSpacing: desktop ? 16 : 10,
|
|
||||||
mainAxisSpacing: desktop ? 16 : 10,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildGridCard(folders[index]),
|
(context, index) => _buildGridCard(folders[index]),
|
||||||
childCount: folders.length,
|
childCount: folders.length,
|
||||||
|
|||||||
@@ -355,13 +355,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
),
|
),
|
||||||
NooPhotoGrid(
|
NooPhotoGrid(
|
||||||
itemCount: group.items.length,
|
itemCount: group.items.length,
|
||||||
columns: isDesktop
|
minTileWidth: 150,
|
||||||
? NooPhotoGrid.desktopColumns
|
|
||||||
: NooLayout.gridColumns(
|
|
||||||
context,
|
|
||||||
phone: NooPhotoGrid.mobileColumns,
|
|
||||||
minTile: 130,
|
|
||||||
),
|
|
||||||
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
horizontal: isDesktop ? NooLayout.gutter(context) : 0,
|
||||||
@@ -393,7 +387,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
? _buildSelectionBar(context, pick, selectedItems)
|
? _buildSelectionBar(context, pick, selectedItems)
|
||||||
: null,
|
: null,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
@@ -653,6 +647,9 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
|
|
||||||
return NooPhotoTile(
|
return NooPhotoTile(
|
||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
|
onSelectionToggle: pick.isPicking && !pick.pickRequest!.allowMultiple
|
||||||
|
? null
|
||||||
|
: () => _toggleSelection(photo),
|
||||||
selectionMode: _isSelecting,
|
selectionMode: _isSelecting,
|
||||||
// No real duration source (no video-metadata extraction anywhere in
|
// No real duration source (no video-metadata extraction anywhere in
|
||||||
// the app) - an empty string still marks it as a video and shows the
|
// the app) - an empty string still marks it as a video and shows the
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
|
|||||||
@@ -123,7 +123,11 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
appBar: desktop
|
appBar: desktop
|
||||||
? NooToolbar(
|
? PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(NooToolbar.outerHeight),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: NooToolbar(
|
||||||
titleWidget: _buildField(onSurface: true),
|
titleWidget: _buildField(onSurface: true),
|
||||||
actions: [
|
actions: [
|
||||||
IconTheme.merge(
|
IconTheme.merge(
|
||||||
@@ -135,6 +139,8 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@@ -223,7 +229,9 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
title: 'No results found',
|
title: 'No results found',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return desktop ? _buildDesktopResults(context) : _buildMobileResults(context);
|
return desktop
|
||||||
|
? _buildDesktopResults(context)
|
||||||
|
: _buildMobileResults(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMessage(
|
Widget _buildMessage(
|
||||||
@@ -304,7 +312,10 @@ class _SearchViewState extends State<SearchView> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const NooFileTableHeader(col2Label: 'Modified', col3Label: 'Location'),
|
const NooFileTableHeader(
|
||||||
|
col2Label: 'Modified',
|
||||||
|
col3Label: 'Location',
|
||||||
|
),
|
||||||
for (final item in _results)
|
for (final item in _results)
|
||||||
NooFileTableRow(
|
NooFileTableRow(
|
||||||
kind: NooFileKind.from(
|
kind: NooFileKind.from(
|
||||||
|
|||||||
@@ -208,14 +208,7 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||||
crossAxisCount: desktop
|
|
||||||
? 5
|
|
||||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
|
||||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
|
||||||
crossAxisSpacing: desktop ? 16 : 10,
|
|
||||||
mainAxisSpacing: desktop ? 16 : 10,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) => _buildGridCard(context, folders[index]),
|
(context, index) => _buildGridCard(context, folders[index]),
|
||||||
childCount: folders.length,
|
childCount: folders.length,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import '../widgets/noo/core/noo_button.dart';
|
|||||||
import '../widgets/noo/files/noo_file_kind.dart';
|
import '../widgets/noo/files/noo_file_kind.dart';
|
||||||
import '../widgets/noo/files/noo_file_row.dart';
|
import '../widgets/noo/files/noo_file_row.dart';
|
||||||
import '../widgets/noo/files/noo_file_table.dart';
|
import '../widgets/noo/files/noo_file_table.dart';
|
||||||
import '../widgets/noo/files/noo_file_tile.dart';
|
|
||||||
import '../widgets/noo/lists/noo_banner.dart';
|
import '../widgets/noo/lists/noo_banner.dart';
|
||||||
import '../widgets/noo/noo_layout.dart';
|
import '../widgets/noo/noo_layout.dart';
|
||||||
import '../widgets/tabs/tab_location.dart';
|
import '../widgets/tabs/tab_location.dart';
|
||||||
@@ -98,7 +97,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: colors.bg,
|
color: NooLayout.contentBackground(context),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
@@ -335,17 +334,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Desktop trash row: like `NooFileTableRow`, but its 120px last column
|
/// Trash actions use the same responsive columns as the other file tables.
|
||||||
/// holds a tonal "Restore" button plus a danger-tonal "Delete" button
|
|
||||||
/// (permanent delete) instead of status icons - `NooFileTableRow` has no
|
|
||||||
/// slot for screen-specific actions there. A labelled pill, not a bare
|
|
||||||
/// overflow icon: it's the row's only destructive action, not a menu of
|
|
||||||
/// several, and desktop already prefers labelled buttons to bare icons
|
|
||||||
/// (see `NooSelectionBar`'s own desktop actions). Column widths
|
|
||||||
/// (180/160/120) are matched by hand to `NooFileTableHeader`'s (private in
|
|
||||||
/// `noo_file_table.dart`) so this still lines up under it; promoting an
|
|
||||||
/// optional `actions` slot onto `NooFileTableRow` would let this fold back
|
|
||||||
/// into the shared component.
|
|
||||||
class _TrashDesktopRow extends StatelessWidget {
|
class _TrashDesktopRow extends StatelessWidget {
|
||||||
final NooFileKind kind;
|
final NooFileKind kind;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -364,61 +353,12 @@ class _TrashDesktopRow extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => NooFileTableRow(
|
||||||
final colors = context.nooColors;
|
kind: kind,
|
||||||
final metaStyle = NooText.meta.copyWith(color: colors.fg3);
|
name: name,
|
||||||
|
col2: col2,
|
||||||
return SizedBox(
|
col3: col3,
|
||||||
height: NooSizes.rowDesktop,
|
actions: Row(
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.sm),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
NooFileTile(kind: kind, size: NooFileTileSize.desktop),
|
|
||||||
const SizedBox(width: NooSpace.sm),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: NooText.body.copyWith(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: colors.fg1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: NooSpace.md),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 180,
|
|
||||||
child: Text(
|
|
||||||
col2,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: metaStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 160,
|
|
||||||
child: Text(
|
|
||||||
col3,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: metaStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 120,
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.scaleDown,
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
NooButton(
|
NooButton(
|
||||||
@@ -436,11 +376,5 @@ class _TrashDesktopRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class AppDrawer extends StatelessWidget {
|
|||||||
return NooDrawer(
|
return NooDrawer(
|
||||||
account: NooDrawerAccount(
|
account: NooDrawerAccount(
|
||||||
avatar: NooAvatar(
|
avatar: NooAvatar(
|
||||||
initials: accountInitial(session.username),
|
initials: accountInitial(session.displayName),
|
||||||
current: true,
|
current: true,
|
||||||
size: 48,
|
size: 48,
|
||||||
),
|
),
|
||||||
name: session.username,
|
name: session.displayName,
|
||||||
subtitle: serverHost(session.serverUrl),
|
subtitle: serverHost(session.serverUrl),
|
||||||
onTap: closeAndOpenSettings,
|
onTap: closeAndOpenSettings,
|
||||||
onSwitchAccount: () => showAccountSwitcher(context),
|
onSwitchAccount: () => showAccountSwitcher(context),
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class AvatarMenuCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
session.username,
|
session.displayName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: NooText.bodyL.copyWith(
|
style: NooText.bodyL.copyWith(
|
||||||
@@ -140,7 +140,7 @@ class AvatarMenuCard extends StatelessWidget {
|
|||||||
onTap: onClose,
|
onTap: onClose,
|
||||||
child: NooAvatar(
|
child: NooAvatar(
|
||||||
key: headerAvatarKey,
|
key: headerAvatarKey,
|
||||||
initials: accountInitial(session.username),
|
initials: accountInitial(session.displayName),
|
||||||
current: true,
|
current: true,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
@@ -159,7 +159,7 @@ class AvatarMenuCard extends StatelessWidget {
|
|||||||
for (final account in session.accounts)
|
for (final account in session.accounts)
|
||||||
if (account.id != session.activeAccountId)
|
if (account.id != session.activeAccountId)
|
||||||
_OtherAccountRow(
|
_OtherAccountRow(
|
||||||
name: account.username,
|
name: account.label,
|
||||||
host: serverHost(account.serverUrl),
|
host: serverHost(account.serverUrl),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onClose();
|
onClose();
|
||||||
@@ -667,7 +667,7 @@ class _AvatarNavigationHostState extends State<AvatarNavigationHost>
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: close,
|
onTap: close,
|
||||||
child: NooAvatar(
|
child: NooAvatar(
|
||||||
initials: accountInitial(session.username),
|
initials: accountInitial(session.displayName),
|
||||||
current: true,
|
current: true,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Tracks pointer input without assuming that a large screen has a mouse.
|
||||||
|
class NooPointerSelection extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
const NooPointerSelection({super.key, required this.child});
|
||||||
|
|
||||||
|
static bool available(BuildContext context) =>
|
||||||
|
context.dependOnInheritedWidgetOfExactType<_PointerScope>()?.available ??
|
||||||
|
false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NooPointerSelection> createState() => _NooPointerSelectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NooPointerSelectionState extends State<NooPointerSelection> {
|
||||||
|
bool trackpad = false;
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.mouseTracker.addListener(_refresh);
|
||||||
|
GestureBinding.instance.pointerRouter.addGlobalRoute(_pointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pointer(PointerEvent event) {
|
||||||
|
if (event.kind == PointerDeviceKind.trackpad && !trackpad) {
|
||||||
|
trackpad = true;
|
||||||
|
_refresh();
|
||||||
|
} else if (event.kind == PointerDeviceKind.touch && trackpad) {
|
||||||
|
trackpad = false;
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refresh() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
});
|
||||||
|
WidgetsBinding.instance.scheduleFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.mouseTracker.removeListener(_refresh);
|
||||||
|
GestureBinding.instance.pointerRouter.removeGlobalRoute(_pointer);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => _PointerScope(
|
||||||
|
available:
|
||||||
|
trackpad || WidgetsBinding.instance.mouseTracker.mouseIsConnected,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PointerScope extends InheritedWidget {
|
||||||
|
final bool available;
|
||||||
|
const _PointerScope({required this.available, required super.child});
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_PointerScope oldWidget) =>
|
||||||
|
available != oldWidget.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NooPointerCheckbox extends StatelessWidget {
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onToggle;
|
||||||
|
const NooPointerCheckbox({super.key, required this.selected, this.onToggle});
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
onToggle == null || !NooPointerSelection.available(context)
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Checkbox(
|
||||||
|
value: selected,
|
||||||
|
onChanged: (_) => onToggle!(),
|
||||||
|
semanticLabel: selected ? 'Deselect item' : 'Select item',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/noo_pointer_selection.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
import 'noo_file_kind.dart';
|
import 'noo_file_kind.dart';
|
||||||
@@ -41,6 +42,7 @@ class NooFileRow extends StatelessWidget {
|
|||||||
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
|
/// `ellipsis` on iOS, `ellipsis-vertical` everywhere else.
|
||||||
final bool iosStyle;
|
final bool iosStyle;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final VoidCallback? onSelectionToggle;
|
||||||
|
|
||||||
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
|
/// Real image/video thumbnail for the tile; see [NooFileTile.thumbnail].
|
||||||
final Widget? thumbnail;
|
final Widget? thumbnail;
|
||||||
@@ -62,6 +64,7 @@ class NooFileRow extends StatelessWidget {
|
|||||||
this.trailing,
|
this.trailing,
|
||||||
this.iosStyle = false,
|
this.iosStyle = false,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.onSelectionToggle,
|
||||||
this.thumbnail,
|
this.thumbnail,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
@@ -93,6 +96,10 @@ class NooFileRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
NooPointerCheckbox(
|
||||||
|
selected: selected,
|
||||||
|
onToggle: onSelectionToggle,
|
||||||
|
),
|
||||||
NooFileTile(kind: kind, thumbnail: thumbnail),
|
NooFileTile(kind: kind, thumbnail: thumbnail),
|
||||||
const SizedBox(width: NooSpace.sm),
|
const SizedBox(width: NooSpace.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/noo_pointer_selection.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
import 'noo_file_kind.dart';
|
import 'noo_file_kind.dart';
|
||||||
@@ -6,9 +7,8 @@ import 'noo_file_row.dart';
|
|||||||
import 'noo_file_tile.dart';
|
import 'noo_file_tile.dart';
|
||||||
import 'noo_status_icon.dart';
|
import 'noo_status_icon.dart';
|
||||||
|
|
||||||
/// Fixed widths of the desktop file table's grid
|
/// Maximum metadata widths for wide file tables. Compact panes collapse
|
||||||
/// (`minmax(0,1fr) 180px 160px 120px`, `DESIGN_SYSTEM.md` 2 "File row").
|
/// the date column and reserve the remaining width for the filename.
|
||||||
/// The first column takes the remaining width.
|
|
||||||
const double _col2Width = 180;
|
const double _col2Width = 180;
|
||||||
const double _col3Width = 160;
|
const double _col3Width = 160;
|
||||||
const double _col4Width = 120;
|
const double _col4Width = 120;
|
||||||
@@ -36,13 +36,33 @@ class _TableColumns extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
|
padding: const EdgeInsets.symmetric(horizontal: _tableInset),
|
||||||
child: Row(
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth;
|
||||||
|
final compact = width < 600;
|
||||||
|
final statusWidth = width < 420 ? 80.0 : _col4Width;
|
||||||
|
final metadataWidth = compact
|
||||||
|
? 72.0
|
||||||
|
: (width * .18).clamp(90.0, _col3Width);
|
||||||
|
final dateWidth = compact
|
||||||
|
? 0.0
|
||||||
|
: (width * .22).clamp(110.0, _col2Width);
|
||||||
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: name),
|
Expanded(child: name),
|
||||||
SizedBox(width: _col2Width, child: col2),
|
if (!compact) SizedBox(width: dateWidth, child: col2),
|
||||||
SizedBox(width: _col3Width, child: col3),
|
if (width >= 420) SizedBox(width: metadataWidth, child: col3),
|
||||||
SizedBox(width: _col4Width, child: col4),
|
SizedBox(
|
||||||
|
width: statusWidth,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: col4,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,7 +170,11 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
final List<NooSyncStatus> statuses;
|
final List<NooSyncStatus> statuses;
|
||||||
final bool favorite;
|
final bool favorite;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final VoidCallback? onSelectionToggle;
|
||||||
final Widget? thumbnail;
|
final Widget? thumbnail;
|
||||||
|
|
||||||
|
/// Optional last-column actions, laid out using the shared column widths.
|
||||||
|
final Widget? actions;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final VoidCallback? onSecondaryTap;
|
final VoidCallback? onSecondaryTap;
|
||||||
@@ -168,7 +192,9 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
this.statuses = const [],
|
this.statuses = const [],
|
||||||
this.favorite = false,
|
this.favorite = false,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.onSelectionToggle,
|
||||||
this.thumbnail,
|
this.thumbnail,
|
||||||
|
this.actions,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.onSecondaryTap,
|
this.onSecondaryTap,
|
||||||
@@ -201,6 +227,10 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
child: _TableColumns(
|
child: _TableColumns(
|
||||||
name: Row(
|
name: Row(
|
||||||
children: [
|
children: [
|
||||||
|
NooPointerCheckbox(
|
||||||
|
selected: selected,
|
||||||
|
onToggle: onSelectionToggle,
|
||||||
|
),
|
||||||
NooFileTile(
|
NooFileTile(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
size: NooFileTileSize.desktop,
|
size: NooFileTileSize.desktop,
|
||||||
@@ -224,7 +254,9 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
col2: dataCell(col2),
|
col2: dataCell(col2),
|
||||||
col3: dataCell(col3),
|
col3: dataCell(col3),
|
||||||
col4: Row(
|
col4:
|
||||||
|
actions ??
|
||||||
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
for (final s in statuses) ...[
|
for (final s in statuses) ...[
|
||||||
@@ -232,10 +264,15 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
if (favorite) ...[
|
if (favorite) ...[
|
||||||
Icon(LucideIcons.star, size: 14, color: colors.accentText),
|
Icon(
|
||||||
|
LucideIcons.star,
|
||||||
|
size: 14,
|
||||||
|
color: colors.accentText,
|
||||||
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
],
|
],
|
||||||
if (onMore != null) NooOverflowButton(size: 32, onTap: onMore),
|
if (onMore != null)
|
||||||
|
NooOverflowButton(size: 32, onTap: onMore),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import '../../../models/selection_action.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// The two swipe actions the design system defines (`DESIGN_SYSTEM.md` 2,
|
/// Swipe blocks share the action bar's identities, icons and default labels.
|
||||||
/// "Swipe action"); which one sits on which side is a user setting
|
typedef NooSwipeActionKind = SelectionActionKind;
|
||||||
/// (Settings -> Swipe on a file).
|
|
||||||
enum NooSwipeActionKind { delete, favorite }
|
|
||||||
|
|
||||||
/// One side's action for [NooSwipeAction].
|
/// One side's action for [NooSwipeAction].
|
||||||
class NooSwipeActionSpec {
|
class NooSwipeActionSpec {
|
||||||
final NooSwipeActionKind kind;
|
final NooSwipeActionKind kind;
|
||||||
final VoidCallback onTriggered;
|
final VoidCallback onTriggered;
|
||||||
|
|
||||||
/// Overrides the default label ("Delete" / "Favorite"), e.g.
|
/// Overrides the action's default label, e.g.
|
||||||
/// "Unfavorite" for an already-starred file.
|
/// "Unfavorite" for an already-starred file.
|
||||||
final String? label;
|
final String? label;
|
||||||
|
|
||||||
/// Overrides the default icon (`trash-2` / `star`).
|
/// Overrides the action's default icon.
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
|
|
||||||
const NooSwipeActionSpec({
|
const NooSwipeActionSpec({
|
||||||
@@ -30,7 +28,7 @@ class NooSwipeActionSpec {
|
|||||||
|
|
||||||
/// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides
|
/// Wraps a row (normally a `NooFileRow`) so dragging it horizontally slides
|
||||||
/// it aside to uncover a 96px action block: delete is white on
|
/// it aside to uncover a 96px action block: delete is white on
|
||||||
/// `danger-fill`, favorite is white on `accent`, each a 20px icon over a
|
/// `danger-fill`, other actions are white on `accent`, each a 20px icon over a
|
||||||
/// 12/600 label. Releasing past half the block snaps it open, otherwise it
|
/// 12/600 label. Releasing past half the block snaps it open, otherwise it
|
||||||
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
|
/// springs shut - both on [NooMotion.base] / [NooMotion.ease], no bounce.
|
||||||
/// Tapping the block fires the action and closes it; tapping the row while
|
/// Tapping the block fires the action and closes it; tapping the row while
|
||||||
@@ -256,9 +254,8 @@ class _ActionBlock extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDelete = spec.kind == NooSwipeActionKind.delete;
|
final isDelete = spec.kind == NooSwipeActionKind.delete;
|
||||||
final bg = isDelete ? colors.dangerFill : colors.accent;
|
final bg = isDelete ? colors.dangerFill : colors.accent;
|
||||||
final icon =
|
final icon = spec.icon ?? spec.kind.icon;
|
||||||
spec.icon ?? (isDelete ? LucideIcons.trash2 : LucideIcons.star);
|
final label = spec.label ?? spec.kind.label;
|
||||||
final label = spec.label ?? (isDelete ? 'Delete' : 'Favorite');
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'noo_grouped_list.dart';
|
|||||||
import 'noo_settings_row.dart';
|
import 'noo_settings_row.dart';
|
||||||
|
|
||||||
/// Bulk actions shown inline before the rest collapse behind "More" - a
|
/// Bulk actions shown inline before the rest collapse behind "More" - a
|
||||||
/// fixed, non-scrolling width on every platform (design canvas
|
/// capped, non-scrolling row adapted to the available pane width (design canvas
|
||||||
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
|
/// https://claude.ai/artifact/3AGPqqMdkLSC2ypCh2CQs4, "Selection action
|
||||||
/// bar"). Mobile is tighter (icons share the bar with the close circle and
|
/// bar"). Mobile is tighter (icons share the bar with the close circle and
|
||||||
/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar
|
/// count on a ~360dp phone); desktop's labelled pills have a wider toolbar
|
||||||
@@ -34,9 +34,9 @@ const int _kDesktopInlineActions = 4;
|
|||||||
/// presentation differ:
|
/// presentation differ:
|
||||||
/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px
|
/// - Mobile: the first [_kMobileInlineActions] actions as plain 20px
|
||||||
/// accent-text icons, no fill.
|
/// accent-text icons, no fill.
|
||||||
/// - Desktop: the first [_kDesktopInlineActions] as labelled tonal pills -
|
/// - Wide panes: up to [_kDesktopInlineActions] as labelled tonal pills -
|
||||||
/// danger-soft/danger for the one labelled "Delete", accent-soft/
|
/// danger-soft/danger for the one labelled "Delete", accent-soft/
|
||||||
/// accent-text otherwise - since desktop has the room for labels.
|
/// accent-text otherwise. Narrow tablet panes use the icon presentation.
|
||||||
///
|
///
|
||||||
/// Any actions beyond that inline count sit behind a trailing "More"
|
/// Any actions beyond that inline count 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
|
||||||
@@ -62,12 +62,43 @@ class NooSelectionBar extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => LayoutBuilder(
|
||||||
|
builder: (context, constraints) => _buildBar(context, constraints.maxWidth),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _buildBar(BuildContext context, double width) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final gutter = isDesktop ? NooSpace.xl : NooSpace.sm;
|
final gutter = isDesktop ? NooSpace.xl : NooSpace.sm;
|
||||||
final inlineCount = isDesktop
|
final labelled =
|
||||||
|
isDesktop &&
|
||||||
|
width >= 700 &&
|
||||||
|
MediaQuery.textScalerOf(context).scale(14) <= 18;
|
||||||
|
var inlineCount = labelled
|
||||||
? _kDesktopInlineActions
|
? _kDesktopInlineActions
|
||||||
: _kMobileInlineActions;
|
: ((width - gutter * 2 - 12 - 140 - 32) / 40).floor().clamp(
|
||||||
|
0,
|
||||||
|
_kMobileInlineActions,
|
||||||
|
);
|
||||||
|
if (labelled) {
|
||||||
|
var used = 0.0;
|
||||||
|
final budget = width - gutter * 2 - 12 - 140 - 85;
|
||||||
|
inlineCount = 0;
|
||||||
|
for (final action in actions.take(_kDesktopInlineActions)) {
|
||||||
|
final text = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: action.label,
|
||||||
|
style: NooText.buttonSm.copyWith(fontSize: 13),
|
||||||
|
),
|
||||||
|
textDirection: Directionality.of(context),
|
||||||
|
textScaler: MediaQuery.textScalerOf(context),
|
||||||
|
)..layout();
|
||||||
|
final pillWidth = text.width + 51;
|
||||||
|
text.dispose();
|
||||||
|
if (used + pillWidth > budget) break;
|
||||||
|
used += pillWidth;
|
||||||
|
inlineCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
final inlineActions = actions.length > inlineCount
|
final inlineActions = actions.length > inlineCount
|
||||||
? actions.sublist(0, inlineCount)
|
? actions.sublist(0, inlineCount)
|
||||||
: actions;
|
: actions;
|
||||||
@@ -111,7 +142,8 @@ class NooSelectionBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: NooSpace.xs),
|
const SizedBox(width: NooSpace.xs),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
'$count selected',
|
'$count selected',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -120,11 +152,11 @@ class NooSelectionBar extends StatelessWidget {
|
|||||||
color: colors.fg1,
|
color: colors.fg1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: NooSpace.xs),
|
const SizedBox(width: NooSpace.xs),
|
||||||
Expanded(
|
Align(
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: isDesktop
|
child: labelled
|
||||||
? _desktopActions(
|
? _desktopActions(
|
||||||
context,
|
context,
|
||||||
colors,
|
colors,
|
||||||
@@ -138,7 +170,6 @@ class NooSelectionBar extends StatelessWidget {
|
|||||||
overflowActions,
|
overflowActions,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/noo_pointer_selection.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// Grid-view file card: a thumbnail area (file-type soft colour + 32px
|
/// Grid-view file card: a thumbnail area (file-type soft colour + 32px
|
||||||
/// icon, or a real [thumbnail]) above the name, an overflow button and a
|
/// icon, or a real [thumbnail]) above the name, an overflow button and a
|
||||||
/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these
|
/// meta line (`DESIGN_SYSTEM.md` section 2, "Grid card"). Mobile lays these
|
||||||
/// out 2-up with a 10px gap, desktop 5-up with a 16px gap - the parent grid
|
/// out with 10px phone / 16px tablet gaps and responsive columns - the parent grid
|
||||||
/// owns that, this card just fills its cell.
|
/// owns that, this card just fills its cell.
|
||||||
///
|
///
|
||||||
/// The placeholder colours/icon are plain parameters rather than a file
|
/// The placeholder colours/icon are plain parameters rather than a file
|
||||||
@@ -30,6 +31,7 @@ class NooGridCard extends StatelessWidget {
|
|||||||
final double thumbnailHeight;
|
final double thumbnailHeight;
|
||||||
|
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final VoidCallback? onSelectionToggle;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onLongPress;
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ class NooGridCard extends StatelessWidget {
|
|||||||
this.thumbnail,
|
this.thumbnail,
|
||||||
this.thumbnailHeight = 104,
|
this.thumbnailHeight = 104,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.onSelectionToggle,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
this.onMore,
|
this.onMore,
|
||||||
@@ -87,8 +90,20 @@ class NooGridCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
thumb,
|
thumb,
|
||||||
|
Positioned(
|
||||||
|
top: 4,
|
||||||
|
left: 4,
|
||||||
|
child: NooPointerCheckbox(
|
||||||
|
selected: selected,
|
||||||
|
onToggle: onSelectionToggle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
NooSpace.sm,
|
NooSpace.sm,
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ class NooPhotoGroupHeader extends StatelessWidget {
|
|||||||
title,
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: NooText.groupHeading.copyWith(height: 1, color: colors.fg1),
|
style: NooText.groupHeading.copyWith(
|
||||||
|
height: 1,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (count != null) ...[
|
if (count != null) ...[
|
||||||
@@ -67,6 +70,7 @@ class NooPhotoGrid extends StatelessWidget {
|
|||||||
final IndexedWidgetBuilder itemBuilder;
|
final IndexedWidgetBuilder itemBuilder;
|
||||||
final int columns;
|
final int columns;
|
||||||
final double gap;
|
final double gap;
|
||||||
|
final double? minTileWidth;
|
||||||
final EdgeInsetsGeometry padding;
|
final EdgeInsetsGeometry padding;
|
||||||
final bool _sliver;
|
final bool _sliver;
|
||||||
|
|
||||||
@@ -77,6 +81,7 @@ class NooPhotoGrid extends StatelessWidget {
|
|||||||
required this.itemBuilder,
|
required this.itemBuilder,
|
||||||
this.columns = 3,
|
this.columns = 3,
|
||||||
this.gap = 2,
|
this.gap = 2,
|
||||||
|
this.minTileWidth,
|
||||||
this.padding = EdgeInsets.zero,
|
this.padding = EdgeInsets.zero,
|
||||||
}) : _sliver = true;
|
}) : _sliver = true;
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ class NooPhotoGrid extends StatelessWidget {
|
|||||||
required this.itemBuilder,
|
required this.itemBuilder,
|
||||||
this.columns = 3,
|
this.columns = 3,
|
||||||
this.gap = 2,
|
this.gap = 2,
|
||||||
|
this.minTileWidth,
|
||||||
this.padding = EdgeInsets.zero,
|
this.padding = EdgeInsets.zero,
|
||||||
}) : _sliver = false;
|
}) : _sliver = false;
|
||||||
|
|
||||||
@@ -96,8 +102,13 @@ class NooPhotoGrid extends StatelessWidget {
|
|||||||
static const desktopColumns = 8;
|
static const desktopColumns = 8;
|
||||||
static const desktopGap = 4.0;
|
static const desktopGap = 4.0;
|
||||||
|
|
||||||
SliverGridDelegate get _delegate =>
|
SliverGridDelegate get _delegate => minTileWidth != null
|
||||||
SliverGridDelegateWithFixedCrossAxisCount(
|
? SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: minTileWidth!,
|
||||||
|
mainAxisSpacing: gap,
|
||||||
|
crossAxisSpacing: gap,
|
||||||
|
)
|
||||||
|
: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: columns,
|
crossAxisCount: columns,
|
||||||
mainAxisSpacing: gap,
|
mainAxisSpacing: gap,
|
||||||
crossAxisSpacing: gap,
|
crossAxisSpacing: gap,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../core/noo_pointer_selection.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class NooPhotoTile extends StatelessWidget {
|
|||||||
final String? videoDuration;
|
final String? videoDuration;
|
||||||
|
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final VoidCallback? onSelectionToggle;
|
||||||
|
|
||||||
/// Shows the selection check circle even when not [selected] (i.e. the
|
/// Shows the selection check circle even when not [selected] (i.e. the
|
||||||
/// grid is in multi-select mode).
|
/// grid is in multi-select mode).
|
||||||
@@ -39,6 +41,7 @@ class NooPhotoTile extends StatelessWidget {
|
|||||||
this.placeholderColor,
|
this.placeholderColor,
|
||||||
this.videoDuration,
|
this.videoDuration,
|
||||||
this.selected = false,
|
this.selected = false,
|
||||||
|
this.onSelectionToggle,
|
||||||
this.selectionMode = false,
|
this.selectionMode = false,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
@@ -63,7 +66,9 @@ class NooPhotoTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
// Selected tiles inset so an accent-soft frame shows around
|
// Selected tiles inset so an accent-soft frame shows around
|
||||||
// the photo, instead of covering it with a tint.
|
// the photo, instead of covering it with a tint.
|
||||||
ColoredBox(color: selected ? colors.accentSoft : Colors.transparent),
|
ColoredBox(
|
||||||
|
color: selected ? colors.accentSoft : Colors.transparent,
|
||||||
|
),
|
||||||
AnimatedPadding(
|
AnimatedPadding(
|
||||||
duration: NooMotion.fast,
|
duration: NooMotion.fast,
|
||||||
curve: NooMotion.ease,
|
curve: NooMotion.ease,
|
||||||
@@ -87,11 +92,21 @@ class NooPhotoTile extends StatelessWidget {
|
|||||||
bottom: 6,
|
bottom: 6,
|
||||||
child: _VideoBadge(duration: videoDuration!),
|
child: _VideoBadge(duration: videoDuration!),
|
||||||
),
|
),
|
||||||
if (selected || selectionMode)
|
if (selected ||
|
||||||
|
selectionMode ||
|
||||||
|
(onSelectionToggle != null &&
|
||||||
|
NooPointerSelection.available(context)))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 6,
|
left: 6,
|
||||||
top: 6,
|
top: 6,
|
||||||
child: _SelectCheck(selected: selected),
|
child:
|
||||||
|
NooPointerSelection.available(context) &&
|
||||||
|
onSelectionToggle != null
|
||||||
|
? NooPointerCheckbox(
|
||||||
|
selected: selected,
|
||||||
|
onToggle: onSelectionToggle,
|
||||||
|
)
|
||||||
|
: _SelectCheck(selected: selected),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -152,9 +167,7 @@ class _SelectCheck extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: selected ? colors.accent : _videoBadgeColor,
|
color: selected ? colors.accent : _videoBadgeColor,
|
||||||
border: selected
|
border: selected ? null : Border.all(color: Colors.white, width: 1.5),
|
||||||
? null
|
|
||||||
: Border.all(color: Colors.white, width: 1.5),
|
|
||||||
),
|
),
|
||||||
child: selected
|
child: selected
|
||||||
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
|
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import '../core/noo_progress_bar.dart';
|
|||||||
class NooSidebar extends StatelessWidget {
|
class NooSidebar extends StatelessWidget {
|
||||||
final Widget? windowControls;
|
final Widget? windowControls;
|
||||||
final Widget? account;
|
final Widget? account;
|
||||||
|
final Widget? search;
|
||||||
|
final bool groupItems;
|
||||||
final List<Widget> items;
|
final List<Widget> items;
|
||||||
final Widget? storage;
|
final Widget? storage;
|
||||||
final Widget? settings;
|
final Widget? settings;
|
||||||
@@ -34,6 +36,8 @@ class NooSidebar extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.windowControls,
|
this.windowControls,
|
||||||
this.account,
|
this.account,
|
||||||
|
this.search,
|
||||||
|
this.groupItems = false,
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
this.storage,
|
this.storage,
|
||||||
this.settings,
|
this.settings,
|
||||||
@@ -46,7 +50,10 @@ class NooSidebar extends StatelessWidget {
|
|||||||
const padding = EdgeInsets.fromLTRB(12, 12, 12, 16);
|
const padding = EdgeInsets.fromLTRB(12, 12, 12, 16);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: NooSizes.sidebarWidth,
|
width: (MediaQuery.sizeOf(context).width * .3).clamp(
|
||||||
|
200.0,
|
||||||
|
NooSizes.sidebarWidth,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.bg,
|
color: colors.bg,
|
||||||
border: showDivider
|
border: showDivider
|
||||||
@@ -76,6 +83,25 @@ class NooSidebar extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
child: account,
|
child: account,
|
||||||
),
|
),
|
||||||
|
if (search != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: search,
|
||||||
|
),
|
||||||
|
if (groupItems)
|
||||||
|
Material(
|
||||||
|
color: colors.surface,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
side: BorderSide(color: colors.line),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: items,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
...items,
|
...items,
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
?storage,
|
?storage,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
/// Defaults to `surface` - the toolbar belongs to the main pane.
|
/// Defaults to `surface` - the toolbar belongs to the main pane.
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
final bool framed;
|
||||||
|
|
||||||
const NooToolbar({
|
const NooToolbar({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -42,6 +43,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
this.onBack,
|
this.onBack,
|
||||||
this.onForward,
|
this.onForward,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
|
this.framed = true,
|
||||||
}) : assert(
|
}) : assert(
|
||||||
title == null || titleWidget == null,
|
title == null || titleWidget == null,
|
||||||
'Pass either title or titleWidget, not both.',
|
'Pass either title or titleWidget, not both.',
|
||||||
@@ -67,7 +69,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
side: BorderSide(color: colors.line),
|
side: framed ? BorderSide(color: colors.line) : BorderSide.none,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: NooSizes.toolbar,
|
height: NooSizes.toolbar,
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'nav/noo_nav_style.dart';
|
import 'nav/noo_nav_style.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
|
||||||
/// The one place screens decide mobile vs desktop layout and iOS vs Android
|
/// The one place screens decide mobile vs desktop layout and iOS vs Android
|
||||||
/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point.
|
/// chrome (DESIGN_SYSTEM.md 3), so every screen switches at the same point.
|
||||||
class NooLayout {
|
class NooLayout {
|
||||||
const NooLayout._();
|
const NooLayout._();
|
||||||
|
|
||||||
/// Window width at which the shell swaps the bottom bar + drawer for the
|
|
||||||
/// desktop sidebar + toolbar, and screens switch to desktop recipes
|
|
||||||
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
|
||||||
static const double desktopBreakpoint = 900;
|
|
||||||
|
|
||||||
/// Shortest window side that counts as a tablet-class window (the usual
|
/// Shortest window side that counts as a tablet-class window (the usual
|
||||||
/// 600dp rule). A phone in landscape is wide but short, so it stays on the
|
/// 600dp rule). A phone in landscape is wide but short, so it stays on the
|
||||||
/// mobile layout.
|
/// mobile layout.
|
||||||
@@ -18,10 +14,13 @@ class NooLayout {
|
|||||||
|
|
||||||
static bool isDesktop(BuildContext context) {
|
static bool isDesktop(BuildContext context) {
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
return size.width >= desktopBreakpoint &&
|
return size.shortestSide >= tabletShortestSide;
|
||||||
size.shortestSide >= tabletShortestSide;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A quieter pane tone keeps cards distinct from their container.
|
||||||
|
static Color contentBackground(BuildContext context) =>
|
||||||
|
isDesktop(context) ? context.nooColors.surface2 : context.nooColors.bg;
|
||||||
|
|
||||||
static NooNavStyle navStyle(BuildContext context) =>
|
static NooNavStyle navStyle(BuildContext context) =>
|
||||||
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
||||||
|
|
||||||
@@ -33,9 +32,23 @@ class NooLayout {
|
|||||||
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
|
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
|
||||||
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
|
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
|
||||||
|
|
||||||
/// Grid column count for [minTile]-wide tiles, never fewer than [phone]
|
/// File cards use the available pane width and a content-sized height.
|
||||||
/// (so phones keep their usual count while wide non-sidebar windows, e.g. a
|
static SliverGridDelegate fileGridDelegate(BuildContext context) {
|
||||||
/// tablet in portrait, get more instead of a few huge tiles).
|
final tablet = isDesktop(context);
|
||||||
|
final scale = MediaQuery.textScalerOf(context);
|
||||||
|
return SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 220,
|
||||||
|
mainAxisExtent:
|
||||||
|
(tablet ? 118 : 104) +
|
||||||
|
26 +
|
||||||
|
scale.scale(14) * 1.2 +
|
||||||
|
scale.scale(12) * 1.3,
|
||||||
|
crossAxisSpacing: tablet ? 16 : 10,
|
||||||
|
mainAxisSpacing: tablet ? 16 : 10,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grid column count for [minTile]-wide tiles, never fewer than [phone].
|
||||||
static int gridColumns(
|
static int gridColumns(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required int phone,
|
required int phone,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ProfileAvatarButton extends StatelessWidget {
|
|||||||
HapticFeedback.selectionClick();
|
HapticFeedback.selectionClick();
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Switched to ${target.username}'),
|
content: Text('Switched to ${target.label}'),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -39,8 +39,8 @@ class ProfileAvatarButton extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = context.watch<SessionController>();
|
final provider = context.watch<SessionController>();
|
||||||
final initial = provider.username.isNotEmpty
|
final initial = provider.displayName.isNotEmpty
|
||||||
? provider.username[0].toUpperCase()
|
? provider.displayName[0].toUpperCase()
|
||||||
: '?';
|
: '?';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
|
import 'settings_section.dart';
|
||||||
|
|
||||||
|
class SettingsAboutSection extends StatefulWidget {
|
||||||
|
const SettingsAboutSection({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsAboutSection> createState() => _SettingsAboutSectionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsAboutSectionState extends State<SettingsAboutSection> {
|
||||||
|
late final Future<PackageInfo> _info = PackageInfo.fromPlatform();
|
||||||
|
|
||||||
|
Future<void> _openLink(String url) async {
|
||||||
|
try {
|
||||||
|
if (await launchUrl(
|
||||||
|
Uri.parse(url),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not open link. Please try again.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => FutureBuilder<PackageInfo>(
|
||||||
|
future: _info,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final info = snapshot.data;
|
||||||
|
final pending = snapshot.hasError ? 'Unavailable' : 'Loading…';
|
||||||
|
return SettingsSection(
|
||||||
|
title: 'About',
|
||||||
|
children: [
|
||||||
|
const NooSettingsRow(
|
||||||
|
label: Text('Noo'),
|
||||||
|
subtitle: Text('An independent Nextcloud client'),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
label: const Text('Version'),
|
||||||
|
trailing: Text(info?.version ?? pending),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
label: const Text('Build number'),
|
||||||
|
trailing: Text(info?.buildNumber ?? pending),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
label: const Text('App identifier'),
|
||||||
|
subtitle: Text(info?.packageName ?? pending),
|
||||||
|
),
|
||||||
|
for (final link in {
|
||||||
|
'Website': 'https://noo.ayushya.dev',
|
||||||
|
'GitHub': 'https://github.com/ayushyamitabh/noo',
|
||||||
|
'Privacy policy': 'https://noo.ayushya.dev/privacy',
|
||||||
|
}.entries)
|
||||||
|
NooSettingsRow(
|
||||||
|
label: Text(link.key),
|
||||||
|
value: '',
|
||||||
|
onTap: () => _openLink(link.value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import '../noo/core/noo_chip.dart';
|
|||||||
import '../noo/core/noo_progress_bar.dart';
|
import '../noo/core/noo_progress_bar.dart';
|
||||||
import '../noo/noo_layout.dart';
|
import '../noo/noo_layout.dart';
|
||||||
import '../shell/shell_common.dart';
|
import '../shell/shell_common.dart';
|
||||||
|
import '../shell/tablet_account_menu.dart';
|
||||||
import 'settings_dialogs.dart';
|
import 'settings_dialogs.dart';
|
||||||
|
|
||||||
/// Settings section 1: the account card (DESIGN_SYSTEM.md 4 - "NooAvatar
|
/// Settings section 1: the account card (DESIGN_SYSTEM.md 4 - "NooAvatar
|
||||||
@@ -18,7 +19,9 @@ import 'settings_dialogs.dart';
|
|||||||
/// the pre-rework card and have no other home, so they stay here as a
|
/// the pre-rework card and have no other home, so they stay here as a
|
||||||
/// button row under the storage meter.
|
/// button row under the storage meter.
|
||||||
class SettingsAccountCard extends StatelessWidget {
|
class SettingsAccountCard extends StatelessWidget {
|
||||||
const SettingsAccountCard({super.key});
|
final bool compact;
|
||||||
|
final VoidCallback? onManage;
|
||||||
|
const SettingsAccountCard({super.key, this.compact = false, this.onManage});
|
||||||
|
|
||||||
Future<void> _refresh(BuildContext context, FilesController files) async {
|
Future<void> _refresh(BuildContext context, FilesController files) async {
|
||||||
await files.refreshData();
|
await files.refreshData();
|
||||||
@@ -48,7 +51,7 @@ class SettingsAccountCard extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
session,
|
session,
|
||||||
accountId: id,
|
accountId: id,
|
||||||
username: session.username,
|
username: session.displayName,
|
||||||
host: serverHost(session.serverUrl),
|
host: serverHost(session.serverUrl),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
);
|
);
|
||||||
@@ -60,21 +63,30 @@ class SettingsAccountCard extends StatelessWidget {
|
|||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final files = context.watch<FilesController>();
|
final files = context.watch<FilesController>();
|
||||||
final quota = files.quota;
|
final quota = files.quota;
|
||||||
final name = quota?.userName ?? session.username;
|
final name = session.displayName;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(NooSpace.lg),
|
padding: EdgeInsets.all(compact ? 10 : NooSpace.lg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surface,
|
color: colors.surface,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.card),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
border: NooLayout.isDesktop(context) ? Border.all(color: colors.line) : null,
|
border: NooLayout.isDesktop(context)
|
||||||
|
? Border.all(color: colors.line)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (compact)
|
||||||
|
TabletAccountMenu(framed: false, onManage: onManage)
|
||||||
|
else
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
NooAvatar(initials: accountInitial(name), current: true, size: 56),
|
NooAvatar(
|
||||||
|
initials: accountInitial(name),
|
||||||
|
current: true,
|
||||||
|
size: 56,
|
||||||
|
),
|
||||||
const SizedBox(width: NooSpace.sm),
|
const SizedBox(width: NooSpace.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -103,10 +115,16 @@ class SettingsAccountCard extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('Storage', style: NooText.label.copyWith(color: colors.fg2)),
|
Text('Storage', style: NooText.label.copyWith(color: colors.fg2)),
|
||||||
Text(
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
quota != null ? quotaDetail(quota) : 'Loading…',
|
quota != null ? quotaDetail(quota) : 'Loading…',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.end,
|
||||||
style: NooText.meta.copyWith(color: colors.fg3),
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -116,7 +134,9 @@ class SettingsAccountCard extends StatelessWidget {
|
|||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [for (final group in quota.groups) NooChip(child: Text(group))],
|
children: [
|
||||||
|
for (final group in quota.groups) NooChip(child: Text(group)),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: NooSpace.md),
|
const SizedBox(height: NooSpace.md),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class SettingsAccountsSection extends StatelessWidget {
|
|||||||
context,
|
context,
|
||||||
session,
|
session,
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
username: account.username,
|
username: account.label,
|
||||||
host: serverHost(account.serverUrl),
|
host: serverHost(account.serverUrl),
|
||||||
isActive: account.id == session.activeAccountId,
|
isActive: account.id == session.activeAccountId,
|
||||||
),
|
),
|
||||||
@@ -45,7 +45,9 @@ class SettingsAccountsSection extends StatelessWidget {
|
|||||||
label: const Text('Add account'),
|
label: const Text('Add account'),
|
||||||
onTap: () => Navigator.push(
|
onTap: () => Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (_) => const LoginView(isAddingAccount: true)),
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const LoginView(isAddingAccount: true),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -79,11 +81,18 @@ class _SavedAccountRow extends StatelessWidget {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
children: [
|
children: [
|
||||||
NooAvatar(initials: accountInitial(account.username), current: isActive, size: 36),
|
NooAvatar(
|
||||||
|
initials: accountInitial(account.label),
|
||||||
|
current: isActive,
|
||||||
|
size: 36,
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -108,15 +117,27 @@ class _SavedAccountRow extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isActive) Icon(LucideIcons.checkCircle, size: 20, color: colors.accentText),
|
if (isActive)
|
||||||
|
Icon(
|
||||||
|
LucideIcons.checkCircle,
|
||||||
|
size: 20,
|
||||||
|
color: colors.accentText,
|
||||||
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: onRemove,
|
onTap: onRemove,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(color: colors.surface2, shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
child: Icon(LucideIcons.trash2, size: 16, color: colors.danger),
|
color: colors.surface2,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.trash2,
|
||||||
|
size: 16,
|
||||||
|
color: colors.danger,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ 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_selection_bar.dart';
|
||||||
import '../noo/lists/noo_settings_row.dart';
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
import '../noo/lists/noo_grouped_list.dart';
|
import '../noo/lists/noo_grouped_list.dart';
|
||||||
import '../noo/core/noo_segmented_control.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
|
||||||
@@ -58,7 +60,11 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
if (!desktop) {
|
if (!desktop) {
|
||||||
return NooGroupedList(
|
return NooGroupedList(
|
||||||
footer: const Text(_subtitle),
|
footer: const Text(_subtitle),
|
||||||
children: [positionRow, orderList],
|
children: [
|
||||||
|
_ActionBarPreview(order: settings.selectionActionOrder),
|
||||||
|
positionRow,
|
||||||
|
orderList,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +78,7 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (SettingsCategoryHeading.shows(context, 'Action bar'))
|
||||||
Text(
|
Text(
|
||||||
'Action bar',
|
'Action bar',
|
||||||
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
style: NooText.cardTitle.copyWith(color: colors.fg1),
|
||||||
@@ -79,6 +86,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),
|
||||||
|
_ActionBarPreview(order: settings.selectionActionOrder),
|
||||||
|
const SizedBox(height: NooSpace.md),
|
||||||
positionRow,
|
positionRow,
|
||||||
const SizedBox(height: NooSpace.md),
|
const SizedBox(height: NooSpace.md),
|
||||||
orderList,
|
orderList,
|
||||||
@@ -88,6 +97,55 @@ class SettingsActionBarSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ActionBarPreview extends StatelessWidget {
|
||||||
|
final List<SelectionActionKind> order;
|
||||||
|
|
||||||
|
const _ActionBarPreview({required this.order});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(NooSpace.sm),
|
||||||
|
color: colors.surface2,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Preview', style: NooText.meta.copyWith(color: colors.fg3)),
|
||||||
|
const SizedBox(height: NooSpace.xs),
|
||||||
|
// Settings has a narrower column than the file browser. Scale the
|
||||||
|
// real bar to fit rather than changing its inline/overflow split.
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SizedBox(
|
||||||
|
width: desktop ? 1000 : 400,
|
||||||
|
child: NooSelectionBar(
|
||||||
|
count: 1,
|
||||||
|
isDesktop: desktop,
|
||||||
|
iosStyle: NooLayout.iosStyle(context),
|
||||||
|
onClose: () {},
|
||||||
|
actions: [
|
||||||
|
for (final kind in order)
|
||||||
|
SelectionAction(
|
||||||
|
kind: kind,
|
||||||
|
icon: kind.icon,
|
||||||
|
label: kind.label,
|
||||||
|
onTap: () => ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${kind.label} preview')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same
|
/// The reorderable [NooTabOrderRow] stack for [SelectionActionKind]s - same
|
||||||
/// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList`
|
/// radius-20, line-divided card `settings_tabs.dart`'s `_TabOrderList`
|
||||||
/// draws, minus the pin toggle (`showPin: false`): there's nothing to
|
/// draws, minus the pin toggle (`showPin: false`): there's nothing to
|
||||||
|
|||||||
@@ -43,22 +43,23 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final settings = context.watch<SettingsController>();
|
final settings = context.watch<SettingsController>();
|
||||||
|
final tablet = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: 'Appearance',
|
title: 'Appearance',
|
||||||
notice:
|
|
||||||
'Bottom bar style, menu style and search in bottom bar have no effect on wide tablets, which use a sidebar.',
|
|
||||||
children: [
|
children: [
|
||||||
_ThemeRow(settings: settings),
|
_ThemeRow(settings: settings),
|
||||||
_BottomBarStyleRow(settings: settings),
|
if (!tablet) _BottomBarStyleRow(settings: settings),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.glassWater,
|
icon: LucideIcons.glassWater,
|
||||||
label: const Text('Frosted glass'),
|
label: const Text('Frosted glass'),
|
||||||
subtitle: const Text(
|
subtitle: Text(
|
||||||
'Blurred bottom navigation, avatar menus and media viewer panels',
|
tablet
|
||||||
|
? 'Blurred media viewer panels'
|
||||||
|
: 'Blurred bottom navigation, avatar menus and media viewer panels',
|
||||||
),
|
),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: settings.bottomBarFrosted,
|
checked: settings.bottomBarFrosted,
|
||||||
@@ -66,8 +67,8 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
if (settings.bottomBarFrosted) _FrostedGlassRow(settings: settings),
|
||||||
_NavMenuStyleRow(settings: settings),
|
if (!tablet) _NavMenuStyleRow(settings: settings),
|
||||||
if (settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
|
if (!tablet && settings.navMenuStyle == NooNavMenuStyle.avatarMenu)
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.userRound,
|
icon: LucideIcons.userRound,
|
||||||
label: const Text('Avatar position'),
|
label: const Text('Avatar position'),
|
||||||
@@ -84,6 +85,7 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!tablet)
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.search,
|
icon: LucideIcons.search,
|
||||||
label: const Text('Search in bottom bar'),
|
label: const Text('Search in bottom bar'),
|
||||||
@@ -98,11 +100,12 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: NooSpace.xl),
|
if (!tablet) const SizedBox(height: NooSpace.xl),
|
||||||
|
if (!tablet)
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: 'Upload button',
|
title: 'Upload button',
|
||||||
notice:
|
notice:
|
||||||
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab. Has no effect on wide tablets.',
|
'Auto shows the label on Files and Photos and shrinks to an icon on other tabs. Mini and Expanded stay the same on every tab.',
|
||||||
children: [_FabStyleRow(settings: settings)],
|
children: [_FabStyleRow(settings: settings)],
|
||||||
),
|
),
|
||||||
const SizedBox(height: NooSpace.xl),
|
const SizedBox(height: NooSpace.xl),
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Future<void> confirmRemoveAccount(
|
|||||||
}) async {
|
}) async {
|
||||||
final hasOtherAccounts = session.accounts.length > 1;
|
final hasOtherAccounts = session.accounts.length > 1;
|
||||||
final message = isActive && hasOtherAccounts
|
final message = isActive && hasOtherAccounts
|
||||||
? 'Remove $username ($host)? Another saved account will become active.'
|
? 'Remove $username ($host)? Its synced files will be removed from this device. Another saved account will become active.'
|
||||||
: 'Remove $username ($host)? You can add it again later.';
|
: 'Remove $username ($host)? Its synced files will be removed from this device. You can add it again later.';
|
||||||
|
|
||||||
var confirmed = false;
|
var confirmed = false;
|
||||||
void cancel() => Navigator.pop(context);
|
void cancel() => Navigator.pop(context);
|
||||||
@@ -80,7 +80,20 @@ Future<void> confirmRemoveAccount(
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
|
try {
|
||||||
await session.removeAccount(accountId);
|
await session.removeAccount(accountId);
|
||||||
|
} catch (error) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Could not remove local account data. Please try again.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Only when this was the last saved account does isLoggedIn drop to false
|
// Only when this was the last saved account does isLoggedIn drop to false
|
||||||
// and main.dart swap the root route to LoginView underneath - pop back to
|
// and main.dart swap the root route to LoginView underneath - pop back to
|
||||||
// it then, rather than leaving Settings stranded on top. Removing a
|
// it then, rather than leaving Settings stranded on top. Removing a
|
||||||
|
|||||||
@@ -6,6 +6,24 @@ import '../noo/noo_layout.dart';
|
|||||||
import '../noo/overlays/noo_dialog.dart';
|
import '../noo/overlays/noo_dialog.dart';
|
||||||
import '../noo/overlays/noo_sheet.dart';
|
import '../noo/overlays/noo_sheet.dart';
|
||||||
|
|
||||||
|
/// Category heading already supplied by the tablet toolbar.
|
||||||
|
class SettingsCategoryHeading extends InheritedWidget {
|
||||||
|
final String title;
|
||||||
|
const SettingsCategoryHeading({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
static bool shows(BuildContext context, String title) =>
|
||||||
|
context
|
||||||
|
.dependOnInheritedWidgetOfExactType<SettingsCategoryHeading>()
|
||||||
|
?.title !=
|
||||||
|
title;
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(SettingsCategoryHeading oldWidget) =>
|
||||||
|
title != oldWidget.title;
|
||||||
|
}
|
||||||
|
|
||||||
/// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a
|
/// One block of Settings (DESIGN_SYSTEM.md 4's 9-part order): a
|
||||||
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
|
/// [NooGroupedList] on mobile (label above a radius-20 card), or a titled,
|
||||||
/// bordered radius-20 card holding a flat row group on desktop
|
/// bordered radius-20 card holding a flat row group on desktop
|
||||||
@@ -34,7 +52,7 @@ class SettingsSection extends StatelessWidget {
|
|||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
if (!NooLayout.isDesktop(context)) {
|
if (!NooLayout.isDesktop(context)) {
|
||||||
return NooGroupedList(
|
return NooGroupedList(
|
||||||
label: title,
|
label: SettingsCategoryHeading.shows(context, title) ? title : null,
|
||||||
notice: notice != null ? NooInfoNote(message: notice!) : null,
|
notice: notice != null ? NooInfoNote(message: notice!) : null,
|
||||||
footer: subtitle != null ? Text(subtitle!) : null,
|
footer: subtitle != null ? Text(subtitle!) : null,
|
||||||
children: children,
|
children: children,
|
||||||
@@ -50,6 +68,7 @@ class SettingsSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (SettingsCategoryHeading.shows(context, title))
|
||||||
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
Text(title, style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@@ -6,26 +6,6 @@ import '../noo/core/noo_toggle.dart';
|
|||||||
import '../noo/lists/noo_settings_row.dart';
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
import 'settings_section.dart';
|
import 'settings_section.dart';
|
||||||
|
|
||||||
/// Per-platform copy for the login-lock row's label (DESIGN_SYSTEM.md 3's
|
|
||||||
/// "Biometric label" row, both the mobile table and the macOS/Windows
|
|
||||||
/// desktop table). Falls back to generic copy on platforms the spec
|
|
||||||
/// doesn't name (Linux, tests).
|
|
||||||
String biometricLabel(TargetPlatform platform) {
|
|
||||||
switch (platform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
return 'Lock with Face ID';
|
|
||||||
case TargetPlatform.android:
|
|
||||||
return 'Lock with fingerprint';
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
return 'Touch ID';
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
return 'Windows Hello';
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
return 'Lock with device credential';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Settings section 3: three independent locks - opening the app, switching
|
/// Settings section 3: three independent locks - opening the app, switching
|
||||||
/// accounts, and revealing hidden files - each 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
|
||||||
@@ -58,14 +38,13 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
final platform = Theme.of(context).platform;
|
|
||||||
|
|
||||||
return SettingsSection(
|
return SettingsSection(
|
||||||
title: 'Security',
|
title: 'Security',
|
||||||
children: [
|
children: [
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.lock,
|
icon: LucideIcons.lock,
|
||||||
label: Text(biometricLabel(platform)),
|
label: const Text('App lock'),
|
||||||
subtitle: const Text(
|
subtitle: const Text(
|
||||||
"Require this device's PIN or biometric to open Noo",
|
"Require this device's PIN or biometric to open Noo",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,19 +6,6 @@ import '../../theme/design_tokens.dart';
|
|||||||
import '../noo/lists/noo_settings_row.dart';
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
import 'settings_section.dart';
|
import 'settings_section.dart';
|
||||||
|
|
||||||
String _swipeActionLabel(SwipeAction action) {
|
|
||||||
switch (action) {
|
|
||||||
case SwipeAction.none:
|
|
||||||
return 'None';
|
|
||||||
case SwipeAction.favorite:
|
|
||||||
return 'Favorite';
|
|
||||||
case SwipeAction.delete:
|
|
||||||
return 'Delete';
|
|
||||||
case SwipeAction.share:
|
|
||||||
return 'Share';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Settings section 8: what swiping a file left/right does in list view.
|
/// Settings section 8: what swiping a file left/right does in list view.
|
||||||
class SettingsSwipeSection extends StatelessWidget {
|
class SettingsSwipeSection extends StatelessWidget {
|
||||||
const SettingsSwipeSection({super.key});
|
const SettingsSwipeSection({super.key});
|
||||||
@@ -35,7 +22,8 @@ class SettingsSwipeSection extends StatelessWidget {
|
|||||||
options: [
|
options: [
|
||||||
for (final action in SwipeAction.values)
|
for (final action in SwipeAction.values)
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
label: Text(_swipeActionLabel(action)),
|
icon: action.selectionKind?.icon,
|
||||||
|
label: Text(action.label),
|
||||||
trailing: action == current
|
trailing: action == current
|
||||||
? Icon(
|
? Icon(
|
||||||
LucideIcons.check,
|
LucideIcons.check,
|
||||||
@@ -59,12 +47,11 @@ class SettingsSwipeSection extends StatelessWidget {
|
|||||||
return SettingsSection(
|
return SettingsSection(
|
||||||
title: 'Swipe on a file',
|
title: 'Swipe on a file',
|
||||||
subtitle: 'Choose what swiping a file left or right does in list view',
|
subtitle: 'Choose what swiping a file left or right does in list view',
|
||||||
notice: 'Swipe actions have no effect on wide tablets.',
|
|
||||||
children: [
|
children: [
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.chevronsRight,
|
icon: LucideIcons.chevronsRight,
|
||||||
label: const Text('Swipe right'),
|
label: const Text('Swipe right'),
|
||||||
value: _swipeActionLabel(settings.swipeRightAction),
|
value: settings.swipeRightAction.label,
|
||||||
onTap: () => _openPicker(
|
onTap: () => _openPicker(
|
||||||
context,
|
context,
|
||||||
title: 'Swipe right',
|
title: 'Swipe right',
|
||||||
@@ -75,7 +62,7 @@ class SettingsSwipeSection extends StatelessWidget {
|
|||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.chevronsLeft,
|
icon: LucideIcons.chevronsLeft,
|
||||||
label: const Text('Swipe left'),
|
label: const Text('Swipe left'),
|
||||||
value: _swipeActionLabel(settings.swipeLeftAction),
|
value: settings.swipeLeftAction.label,
|
||||||
onTap: () => _openPicker(
|
onTap: () => _openPicker(
|
||||||
context,
|
context,
|
||||||
title: 'Swipe left',
|
title: 'Swipe left',
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class SettingsTabsSection extends StatelessWidget {
|
|||||||
final desktop = NooLayout.isDesktop(context);
|
final desktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final optionRows = [
|
final optionRows = [
|
||||||
|
if (!desktop)
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.arrowUpDown,
|
icon: LucideIcons.arrowUpDown,
|
||||||
label: const Text('Tap tab to scroll to top'),
|
label: const Text('Tap tab to scroll to top'),
|
||||||
@@ -136,6 +137,7 @@ class SettingsTabsSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (SettingsCategoryHeading.shows(context, 'Tabs'))
|
||||||
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
Text('Tabs', style: NooText.cardTitle.copyWith(color: colors.fg1)),
|
||||||
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)),
|
||||||
|
|||||||
@@ -510,10 +510,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
const SizedBox(height: NooSpace.xs),
|
const SizedBox(height: NooSpace.xs),
|
||||||
NooPersonAccessRow(
|
NooPersonAccessRow(
|
||||||
avatar: NooAvatar(
|
avatar: NooAvatar(
|
||||||
initials: _initial(session.username),
|
initials: _initial(session.displayName),
|
||||||
current: true,
|
current: true,
|
||||||
),
|
),
|
||||||
name: session.username,
|
name: session.displayName,
|
||||||
owner: true,
|
owner: true,
|
||||||
),
|
),
|
||||||
for (final share in peopleShares)
|
for (final share in peopleShares)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ void showAccountSwitcher(BuildContext context) {
|
|||||||
children: [
|
children: [
|
||||||
for (final account in session.accounts)
|
for (final account in session.accounts)
|
||||||
_AccountSwitchRow(
|
_AccountSwitchRow(
|
||||||
name: account.username,
|
name: account.label,
|
||||||
host: serverHost(account.serverUrl),
|
host: serverHost(account.serverUrl),
|
||||||
active: account.id == session.activeAccountId,
|
active: account.id == session.activeAccountId,
|
||||||
onTap: account.id == session.activeAccountId
|
onTap: account.id == session.activeAccountId
|
||||||
@@ -278,7 +278,7 @@ class ShellAvatarButton extends StatelessWidget {
|
|||||||
radius: hitBox / 2,
|
radius: hitBox / 2,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: NooAvatar(
|
child: NooAvatar(
|
||||||
initials: accountInitial(session.username),
|
initials: accountInitial(session.displayName),
|
||||||
current: true,
|
current: true,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/session_controller.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../../views/login_view.dart';
|
||||||
|
import '../noo/core/noo_avatar.dart';
|
||||||
|
import '../noo/core/noo_button.dart';
|
||||||
|
import 'shell_common.dart';
|
||||||
|
|
||||||
|
class TabletAccountMenu extends StatefulWidget {
|
||||||
|
final bool framed;
|
||||||
|
final VoidCallback? onManage;
|
||||||
|
const TabletAccountMenu({super.key, this.framed = true, this.onManage});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TabletAccountMenu> createState() => _TabletAccountMenuState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabletAccountMenuState extends State<TabletAccountMenu> {
|
||||||
|
bool expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final session = context.watch<SessionController>();
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final duration = MediaQuery.disableAnimationsOf(context)
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 340);
|
||||||
|
Widget accountRow(
|
||||||
|
String name, {
|
||||||
|
bool current = false,
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) => InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
NooAvatar(
|
||||||
|
initials: accountInitial(name),
|
||||||
|
current: current,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.body.copyWith(fontSize: 14, color: colors.fg1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (current)
|
||||||
|
AnimatedRotation(
|
||||||
|
turns: expanded ? .5 : 0,
|
||||||
|
duration: duration,
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
child: Icon(
|
||||||
|
LucideIcons.chevronDown,
|
||||||
|
size: 18,
|
||||||
|
color: colors.accentText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Material(
|
||||||
|
color: widget.framed ? colors.surface : Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
side: widget.framed ? BorderSide(color: colors.line) : BorderSide.none,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
accountRow(
|
||||||
|
session.displayName,
|
||||||
|
current: true,
|
||||||
|
onTap: () => setState(() => expanded = !expanded),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: duration,
|
||||||
|
curve: Curves.easeInOutCubicEmphasized,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: expanded
|
||||||
|
? Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final account in session.accounts)
|
||||||
|
if (account.id != session.activeAccountId)
|
||||||
|
accountRow(
|
||||||
|
account.label,
|
||||||
|
onTap: () async {
|
||||||
|
await session.switchAccount(account.id);
|
||||||
|
if (mounted) setState(() => expanded = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 0, 10, 10),
|
||||||
|
child: Column(
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
NooButton(
|
||||||
|
size: NooButtonSize.compact,
|
||||||
|
fullWidth: true,
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const LoginView(isAddingAccount: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Add Account'),
|
||||||
|
),
|
||||||
|
NooButton(
|
||||||
|
size: NooButtonSize.compact,
|
||||||
|
fullWidth: true,
|
||||||
|
variant: NooButtonVariant.secondary,
|
||||||
|
onTap:
|
||||||
|
widget.onManage ??
|
||||||
|
() => openSettings(context),
|
||||||
|
child: const Text('Manage Accounts'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import 'noo/noo_layout.dart';
|
||||||
|
|
||||||
/// Wraps [child] as a sliver that pins to the top of the scroll view when
|
/// Wraps [child] as a sliver that pins to the top of the scroll view when
|
||||||
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
|
/// used with `SliverPersistentHeader(pinned: true, ...)`, or scrolls away
|
||||||
@@ -30,20 +30,11 @@ class StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|||||||
double shrinkOffset,
|
double shrinkOffset,
|
||||||
bool overlapsContent,
|
bool overlapsContent,
|
||||||
) {
|
) {
|
||||||
// `context.nooColors.bg`, not `Theme.of(context).colorScheme.surface` -
|
// Match the pane so pinned controls preserve its rounded silhouette.
|
||||||
// the latter is Flutter's own Material 3 scheme, seeded from the user's
|
|
||||||
// accent color choice (see AppTheme.light/dark), so it carried a faint
|
|
||||||
// hue of whatever accent is picked, and didn't match the plain
|
|
||||||
// `colors.bg` every one of these screens' own `ColoredBox` background
|
|
||||||
// uses. `bg`, not `surface`, so the sort/filter chips and List/Grid
|
|
||||||
// toggle riding on top of this (each already `colors.surface`-filled)
|
|
||||||
// still pop against it, the same as they do everywhere else in the app
|
|
||||||
// - matching `surface` here would make this pinned header the one place
|
|
||||||
// they'd flatten into their background instead. `surfaceTintColor:
|
|
||||||
// Colors.transparent` guards against the same M3 elevation-tint
|
|
||||||
// behavior even if this ever gets a non-zero elevation.
|
|
||||||
return Material(
|
return Material(
|
||||||
color: floating ? Colors.transparent : context.nooColors.bg,
|
color: floating
|
||||||
|
? Colors.transparent
|
||||||
|
: NooLayout.contentBackground(context),
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class MediaPdfPreview extends StatefulWidget {
|
|||||||
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
/// `FileViewerScreen.localPathResolver`'s doc comment.
|
||||||
final Future<String?> Function(NextcloudItem item)? localPathResolver;
|
final Future<String?> Function(NextcloudItem item)? localPathResolver;
|
||||||
|
|
||||||
/// Space reserved above the page for an overlaid top bar (0 when hidden).
|
/// Stable space reserved above the page for the overlaid top bar.
|
||||||
final double topInset;
|
final double topInset;
|
||||||
|
|
||||||
const MediaPdfPreview({
|
const MediaPdfPreview({
|
||||||
|
|||||||
@@ -648,6 +648,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "10.2.2"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.0"
|
||||||
path:
|
path:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -965,6 +981,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
url_launcher:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: url_launcher
|
||||||
|
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.3"
|
||||||
|
url_launcher_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_android
|
||||||
|
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.3.33"
|
||||||
|
url_launcher_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_ios
|
||||||
|
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.4.2"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -973,6 +1013,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.3"
|
version: "3.2.3"
|
||||||
|
url_launcher_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: url_launcher_macos
|
||||||
|
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.6"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-3
@@ -14,9 +14,7 @@ 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
|
version: 1.0.3+6
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
|
||||||
version: 1.0.2+5
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@@ -59,6 +57,8 @@ dependencies:
|
|||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
connectivity_plus: ^6.1.5
|
connectivity_plus: ^6.1.5
|
||||||
flutter_markdown_plus: ^1.0.12
|
flutter_markdown_plus: ^1.0.12
|
||||||
|
package_info_plus: ^10.2.2
|
||||||
|
url_launcher: ^6.3.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -117,4 +117,67 @@ void main() {
|
|||||||
connectivity.dispose();
|
connectivity.dispose();
|
||||||
session.dispose();
|
session.dispose();
|
||||||
});
|
});
|
||||||
|
for (final fails in [false, true]) {
|
||||||
|
test('account removal awaits native cleanup (failure: $fails)', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'account_migration_v1_done': true,
|
||||||
|
'accounts_list': jsonEncode([
|
||||||
|
const SavedAccount(
|
||||||
|
id: accountId,
|
||||||
|
serverUrl: 'https://server.example.com',
|
||||||
|
username: 'alice',
|
||||||
|
).toJson(),
|
||||||
|
]),
|
||||||
|
'active_account_id': accountId,
|
||||||
|
'acct_${accountId}_ui_synced_folders': ['/Photos'],
|
||||||
|
});
|
||||||
|
final connectivity = ConnectivityController();
|
||||||
|
await pumpUntil(() => connectivity.isOffline);
|
||||||
|
final session = SessionController(connectivity);
|
||||||
|
await pumpUntil(() => session.isLoggedIn);
|
||||||
|
const channel = MethodChannel('dev.ayushya.noo/sync_service');
|
||||||
|
var cleanupCalled = false;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
if (call.method == 'removeAccountData') {
|
||||||
|
expect(call.arguments['accountId'], accountId);
|
||||||
|
expect(session.accounts, isNotEmpty);
|
||||||
|
cleanupCalled = true;
|
||||||
|
if (fails) throw PlatformException(code: 'cleanup_failed');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (fails) {
|
||||||
|
await expectLater(
|
||||||
|
session.removeAccount(accountId),
|
||||||
|
throwsA(isA<PlatformException>()),
|
||||||
|
);
|
||||||
|
expect(session.accounts, isNotEmpty);
|
||||||
|
expect(
|
||||||
|
(await SharedPreferences.getInstance()).getStringList(
|
||||||
|
'acct_${accountId}_ui_synced_folders',
|
||||||
|
),
|
||||||
|
['/Photos'],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await session.removeAccount(accountId);
|
||||||
|
expect(session.accounts, isEmpty);
|
||||||
|
expect(session.isLoggedIn, false);
|
||||||
|
expect(
|
||||||
|
(await SharedPreferences.getInstance()).containsKey(
|
||||||
|
'acct_${accountId}_ui_synced_folders',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(cleanupCalled, true);
|
||||||
|
} finally {
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null);
|
||||||
|
session.dispose();
|
||||||
|
connectivity.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/models/saved_account.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('display name survives persistence without changing login identity', () {
|
||||||
|
const account = SavedAccount(
|
||||||
|
id: 'id',
|
||||||
|
serverUrl: 'https://cloud.example',
|
||||||
|
username: 'alice123',
|
||||||
|
displayName: ' Alice Smith ',
|
||||||
|
);
|
||||||
|
final restored = SavedAccount.fromJson(account.toJson());
|
||||||
|
expect(restored.label, 'Alice Smith');
|
||||||
|
expect(restored.username, 'alice123');
|
||||||
|
expect(restored.id, 'id');
|
||||||
|
});
|
||||||
|
test('legacy accounts and blank names fall back to username', () {
|
||||||
|
final legacy = SavedAccount.fromJson({
|
||||||
|
'id': 'id',
|
||||||
|
'serverUrl': 'https://cloud.example',
|
||||||
|
'username': 'alice123',
|
||||||
|
});
|
||||||
|
expect(legacy.label, 'alice123');
|
||||||
|
expect(
|
||||||
|
const SavedAccount(
|
||||||
|
id: 'id',
|
||||||
|
serverUrl: '',
|
||||||
|
username: 'alice123',
|
||||||
|
displayName: ' ',
|
||||||
|
).label,
|
||||||
|
'alice123',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:noo/widgets/settings/settings_about.dart';
|
||||||
|
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
||||||
|
import 'package:noo/views/search_view.dart';
|
||||||
|
import 'package:noo/widgets/shell/tablet_account_menu.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
@@ -80,9 +85,15 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> pumpSettings(WidgetTester tester) async {
|
Future<void> pumpSettings(
|
||||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
WidgetTester tester, {
|
||||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
Widget home = const AccountView(),
|
||||||
|
Size size = const Size(400, 800),
|
||||||
|
}) async {
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
@@ -102,7 +113,7 @@ void main() {
|
|||||||
],
|
],
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
||||||
home: const AccountView(),
|
home: home,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -138,6 +149,7 @@ void main() {
|
|||||||
'Tabs',
|
'Tabs',
|
||||||
'Action bar',
|
'Action bar',
|
||||||
'Swipe on a file',
|
'Swipe on a file',
|
||||||
|
'About',
|
||||||
];
|
];
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
@@ -195,4 +207,95 @@ void main() {
|
|||||||
expect(find.text('alice'), findsOneWidget);
|
expect(find.text('alice'), findsOneWidget);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
testWidgets(
|
||||||
|
'tablet hides phone navigation preferences and keeps viewer glass',
|
||||||
|
(tester) async {
|
||||||
|
await pumpSettings(tester, size: const Size(768, 1024));
|
||||||
|
expect(find.text('Swipe on a file'), findsNothing);
|
||||||
|
await tester.tap(find.text('Appearance'));
|
||||||
|
await settleNav(tester);
|
||||||
|
for (final label in [
|
||||||
|
'Bottom bar style',
|
||||||
|
'Menu style',
|
||||||
|
'Avatar position',
|
||||||
|
'Search in bottom bar',
|
||||||
|
'Upload button',
|
||||||
|
]) {
|
||||||
|
expect(find.text(label), findsNothing);
|
||||||
|
}
|
||||||
|
expect(find.text('Frosted glass'), findsOneWidget);
|
||||||
|
expect(find.text('Theme'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Tabs'));
|
||||||
|
await settleNav(tester);
|
||||||
|
expect(find.text('Tap tab to scroll to top'), findsNothing);
|
||||||
|
expect(find.text('Default tab'), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('About reads installed version and build', (tester) async {
|
||||||
|
PackageInfo.setMockInitialValues(
|
||||||
|
appName: 'Noo',
|
||||||
|
packageName: 'dev.ayushya.noo',
|
||||||
|
version: '2.3.4',
|
||||||
|
buildNumber: '42',
|
||||||
|
buildSignature: '',
|
||||||
|
);
|
||||||
|
await pumpSettings(
|
||||||
|
tester,
|
||||||
|
home: const Scaffold(body: SettingsAboutSection()),
|
||||||
|
);
|
||||||
|
expect(find.text('2.3.4'), findsOneWidget);
|
||||||
|
expect(find.text('42'), findsOneWidget);
|
||||||
|
expect(find.text('dev.ayushya.noo'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('tablet Search header stays below status bar', (tester) async {
|
||||||
|
tester.view.padding = const FakeViewPadding(top: 48);
|
||||||
|
addTearDown(tester.view.resetPadding);
|
||||||
|
await pumpSettings(
|
||||||
|
tester,
|
||||||
|
home: const SearchView(),
|
||||||
|
size: const Size(768, 1024),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byType(NooSearchField)).dy,
|
||||||
|
greaterThanOrEqualTo(48),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'portrait tablet Settings selects sections inline and expands accounts',
|
||||||
|
(tester) async {
|
||||||
|
await pumpSettings(tester, size: const Size(768, 1024));
|
||||||
|
await tester.tap(find.text('Security'));
|
||||||
|
await settleNav(tester);
|
||||||
|
expect(
|
||||||
|
find.text("Require this device's PIN or biometric to open Noo"),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// One sidebar label and one toolbar title; no repeated card heading.
|
||||||
|
expect(find.text('Security'), findsNWidgets(2));
|
||||||
|
expect(find.byType(NooTopBarBack), findsNothing);
|
||||||
|
final account = find.descendant(
|
||||||
|
of: find.byType(TabletAccountMenu),
|
||||||
|
matching: find.text('alice'),
|
||||||
|
);
|
||||||
|
final menuHeight = tester.getSize(find.byType(TabletAccountMenu)).height;
|
||||||
|
await tester.tap(account);
|
||||||
|
await settleNav(tester);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byType(TabletAccountMenu)).height,
|
||||||
|
greaterThan(menuHeight),
|
||||||
|
);
|
||||||
|
expect(find.text('Manage Accounts'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Manage Accounts'));
|
||||||
|
await settleNav(tester);
|
||||||
|
expect(
|
||||||
|
find.text("Require this device's PIN or biometric to open Noo"),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:noo/services/nextcloud_service.dart';
|
|||||||
import 'package:noo/views/file_viewer_screen.dart';
|
import 'package:noo/views/file_viewer_screen.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_button.dart';
|
import 'package:noo/widgets/noo/core/noo_button.dart';
|
||||||
import 'package:noo/widgets/viewer/media_action_bar.dart';
|
import 'package:noo/widgets/viewer/media_action_bar.dart';
|
||||||
|
import 'package:noo/widgets/viewer/media_pdf_preview.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../widgets/noo/noo_test_utils.dart';
|
import '../widgets/noo/noo_test_utils.dart';
|
||||||
@@ -94,6 +95,20 @@ void main() {
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testWidgets('PDF viewport remains fixed when toolbar hides', (tester) async {
|
||||||
|
await mount(tester, const Size(390, 800), name: 'document.pdf');
|
||||||
|
final before = tester
|
||||||
|
.widget<MediaPdfPreview>(find.byType(MediaPdfPreview))
|
||||||
|
.topInset;
|
||||||
|
await tester.tapAt(const Offset(195, 350));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(
|
||||||
|
tester.widget<MediaPdfPreview>(find.byType(MediaPdfPreview)).topInset,
|
||||||
|
before,
|
||||||
|
);
|
||||||
|
expect(before, greaterThan(0));
|
||||||
|
});
|
||||||
|
|
||||||
for (final width in [390.0, 1200.0]) {
|
for (final width in [390.0, 1200.0]) {
|
||||||
testWidgets('text actions use the top bar at width $width', (tester) async {
|
testWidgets('text actions use the top bar at width $width', (tester) async {
|
||||||
final service = await mount(tester, Size(width, 800));
|
final service = await mount(tester, Size(width, 800));
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/widgets/noo/lists/noo_selection_bar.dart';
|
||||||
|
import 'package:noo/models/selection_action.dart';
|
||||||
|
import 'package:noo/widgets/noo/noo_layout.dart';
|
||||||
|
import 'package:noo/widgets/noo/media/noo_grid_card.dart';
|
||||||
|
import 'package:noo/widgets/noo/files/noo_file_table.dart';
|
||||||
|
import 'package:noo/widgets/noo/files/noo_file_kind.dart';
|
||||||
|
import 'noo_test_utils.dart';
|
||||||
|
import 'package:noo/widgets/noo/media/noo_photo_group.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpNooTests();
|
||||||
|
testWidgets('photo columns follow the pane rather than the tablet width', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
height: 400,
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
NooPhotoGrid(
|
||||||
|
minTileWidth: 150,
|
||||||
|
itemCount: 6,
|
||||||
|
itemBuilder: (_, index) =>
|
||||||
|
SizedBox(key: ValueKey('photo-$index')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final first = tester.getRect(find.byKey(const ValueKey('photo-0')));
|
||||||
|
final third = tester.getRect(find.byKey(const ValueKey('photo-2')));
|
||||||
|
final fourth = tester.getRect(find.byKey(const ValueKey('photo-3')));
|
||||||
|
expect(first.width, greaterThan(100));
|
||||||
|
expect(third.right, lessThanOrEqualTo(first.left + 400));
|
||||||
|
expect(fourth.top, greaterThan(first.top));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
for (final width in [320.0, 430.0, 700.0]) {
|
||||||
|
testWidgets('selection actions fit portrait tablet pane $width', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
builder: (_, child) => Material(child: child),
|
||||||
|
home: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: NooSelectionBar(
|
||||||
|
count: 100,
|
||||||
|
isDesktop: true,
|
||||||
|
onClose: () {},
|
||||||
|
actions: [
|
||||||
|
for (final kind in SelectionActionKind.values)
|
||||||
|
SelectionAction(
|
||||||
|
kind: kind,
|
||||||
|
icon: kind.icon,
|
||||||
|
label: kind.label,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(find.text('100 selected'), findsOneWidget);
|
||||||
|
});
|
||||||
|
testWidgets('file cards fit pane width $width', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
builder: (_, child) => Material(child: child),
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(
|
||||||
|
size: Size(768, 1024),
|
||||||
|
textScaler: TextScaler.linear(1.4),
|
||||||
|
),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
height: 500,
|
||||||
|
child: GridView.builder(
|
||||||
|
gridDelegate: NooLayout.fileGridDelegate(context),
|
||||||
|
itemCount: 6,
|
||||||
|
itemBuilder: (_, index) => const NooGridCard(
|
||||||
|
name: 'Long file name.pdf',
|
||||||
|
meta: '2 MB',
|
||||||
|
thumbnailHeight: 118,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byType(NooGridCard).first).height,
|
||||||
|
lessThan(200),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
testWidgets('file table preserves name space at $width', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
builder: (_, child) => Material(child: child),
|
||||||
|
home: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: const NooFileTableRow(
|
||||||
|
kind: NooFileKind.folder,
|
||||||
|
name: 'Important project documents',
|
||||||
|
col2: 'Yesterday',
|
||||||
|
col3: 'Folder',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.text('Important project documents')).width,
|
||||||
|
greaterThan(80),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:noo/widgets/noo/files/noo_swipe_action.dart';
|
import 'package:noo/widgets/noo/files/noo_swipe_action.dart';
|
||||||
|
import 'package:noo/models/selection_action.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
|
||||||
import 'noo_test_utils.dart';
|
import 'noo_test_utils.dart';
|
||||||
|
|
||||||
@@ -8,6 +10,52 @@ void main() {
|
|||||||
setUpNooTests();
|
setUpNooTests();
|
||||||
|
|
||||||
group('NooSwipeAction', () {
|
group('NooSwipeAction', () {
|
||||||
|
test('every action-bar action has a swipe setting', () {
|
||||||
|
expect(
|
||||||
|
SwipeAction.values
|
||||||
|
.map((action) => action.selectionKind)
|
||||||
|
.whereType<SelectionActionKind>()
|
||||||
|
.toSet(),
|
||||||
|
SelectionActionKind.values.toSet(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (final kind in SelectionActionKind.values) {
|
||||||
|
testNooWidgets('a full swipe runs ${kind.label}', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var triggered = 0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 64,
|
||||||
|
child: NooSwipeAction(
|
||||||
|
startAction: NooSwipeActionSpec(
|
||||||
|
kind: kind,
|
||||||
|
onTriggered: () => triggered++,
|
||||||
|
),
|
||||||
|
child: const ColoredBox(
|
||||||
|
color: Colors.white,
|
||||||
|
child: SizedBox(width: 360, height: 64),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
await tester.drag(find.byType(NooSwipeAction), const Offset(60, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(triggered, 0);
|
||||||
|
expect(find.text(kind.label), findsOneWidget);
|
||||||
|
expect(find.byIcon(kind.icon), findsOneWidget);
|
||||||
|
await tester.drag(find.byType(NooSwipeAction), const Offset(300, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(triggered, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
testNooWidgets('a moderate swipe opens the action without triggering it', (
|
testNooWidgets('a moderate swipe opens the action without triggering it', (
|
||||||
tester,
|
tester,
|
||||||
theme,
|
theme,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/widgets/noo/noo_layout.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
|
||||||
|
for (final size in [
|
||||||
|
const Size(600, 960),
|
||||||
|
const Size(768, 1024),
|
||||||
|
const Size(1024, 768),
|
||||||
|
const Size(430, 932),
|
||||||
|
const Size(932, 430),
|
||||||
|
const Size(599, 1024),
|
||||||
|
]) {
|
||||||
|
testWidgets('$platform sidebar at $size', (tester) async {
|
||||||
|
bool? sidebar;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: ThemeData(platform: platform),
|
||||||
|
home: MediaQuery(
|
||||||
|
data: MediaQueryData(size: size),
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
sidebar = NooLayout.isDesktop(context);
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(sidebar, size.shortestSide >= 600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/widgets/noo/core/noo_pointer_selection.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('mouse reveals checkbox; clicking selects without opening item', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var opened = 0;
|
||||||
|
var toggled = 0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: NooPointerSelection(
|
||||||
|
child: Scaffold(
|
||||||
|
body: GestureDetector(
|
||||||
|
onTap: () => opened++,
|
||||||
|
child: NooPointerCheckbox(
|
||||||
|
selected: false,
|
||||||
|
onToggle: () => toggled++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(Checkbox), findsNothing);
|
||||||
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await mouse.addPointer(location: const Offset(10, 10));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Checkbox), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(Checkbox));
|
||||||
|
expect(toggled, 1);
|
||||||
|
expect(opened, 0);
|
||||||
|
await mouse.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Checkbox), findsNothing);
|
||||||
|
});
|
||||||
|
testWidgets('nonselectable items never show checkboxes', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: NooPointerSelection(child: NooPointerCheckbox(selected: false)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||||
|
await mouse.addPointer(location: const Offset(10, 10));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Checkbox), findsNothing);
|
||||||
|
await mouse.removePointer();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/models/selection_action.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
import 'package:noo/widgets/noo/lists/noo_selection_bar.dart';
|
||||||
|
import 'package:noo/widgets/settings/settings_action_bar.dart';
|
||||||
|
import 'package:noo/widgets/settings/settings_swipe.dart';
|
||||||
|
|
||||||
|
import 'noo/noo_test_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpNooTests();
|
||||||
|
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||||
|
|
||||||
|
for (final desktop in [false, true]) {
|
||||||
|
testNooWidgets(
|
||||||
|
'action preview follows reorder and exposes overflow (${desktop ? 'tablet' : 'phone'})',
|
||||||
|
(tester, theme, colors) async {
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
tester.view.physicalSize = desktop
|
||||||
|
? const Size(1200, 900)
|
||||||
|
: const Size(380, 900);
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final settings = SettingsController();
|
||||||
|
addTearDown(settings.dispose);
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
ChangeNotifierProvider.value(
|
||||||
|
value: settings,
|
||||||
|
child: SizedBox(
|
||||||
|
width: desktop ? 640 : 340,
|
||||||
|
child: const SingleChildScrollView(
|
||||||
|
child: SettingsActionBarSection(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
surfaceSize: desktop ? const Size(1200, 900) : const Size(380, 900),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final bar = find.byType(NooSelectionBar);
|
||||||
|
expect(bar, findsOneWidget);
|
||||||
|
expect(tester.widget<NooSelectionBar>(bar).isDesktop, desktop);
|
||||||
|
expect(find.text('Preview'), findsOneWidget);
|
||||||
|
settings.setSelectionActionOrder([
|
||||||
|
SelectionActionKind.details,
|
||||||
|
...SelectionActionKind.values.where(
|
||||||
|
(kind) => kind != SelectionActionKind.details,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(of: bar, matching: find.byIcon(LucideIcons.info)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Details preview'), findsOneWidget);
|
||||||
|
await tester.tap(
|
||||||
|
find.descendant(
|
||||||
|
of: bar,
|
||||||
|
matching: desktop
|
||||||
|
? find.text('More')
|
||||||
|
: find.byIcon(LucideIcons.ellipsisVertical),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Sync to device'), findsNWidgets(2));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testNooWidgets('swipe picker offers every action and saves the choice', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
colors,
|
||||||
|
) async {
|
||||||
|
final settings = SettingsController();
|
||||||
|
addTearDown(settings.dispose);
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
ChangeNotifierProvider.value(
|
||||||
|
value: settings,
|
||||||
|
child: const SettingsSwipeSection(),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Swipe right'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
for (final kind in SelectionActionKind.values) {
|
||||||
|
expect(find.text(kind.label), findsAtLeastNWidgets(1));
|
||||||
|
}
|
||||||
|
await tester.ensureVisible(find.text('Details'));
|
||||||
|
await tester.tap(find.text('Details'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(settings.swipeRightAction, SwipeAction.details);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString('ui_swipe_right_action'), 'details');
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user