Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad37e4a36 | ||
|
|
1df27bdc13 | ||
|
|
3a4d6142e7 | ||
|
|
5136ea2eb3 | ||
|
|
d938df6aee | ||
|
|
4611bc2142 | ||
|
|
0b7df833f6 | ||
|
|
ed747a0407 | ||
|
|
9c9e57ec22 | ||
|
|
8aa7015c3f | ||
|
|
88abca647d | ||
|
|
65b772d6e7 | ||
|
|
8179bf9683 | ||
|
|
af69e5e751 | ||
|
|
1a232fc71a | ||
|
|
16aadda7b8 | ||
|
|
fb4829d035 | ||
|
|
9f47d0a3e3 | ||
|
|
e6d96a6f9f | ||
|
|
840ec4c301 | ||
|
|
aeea4847b4 | ||
|
|
43f36ed532 | ||
|
|
5e5acc3fc6 | ||
|
|
5dea9038f7 | ||
|
|
a8b14b8744 | ||
|
|
5f4caa2a3b | ||
|
|
4927416277 | ||
|
|
58312511c5 | ||
|
|
a70be19f80 |
@@ -275,7 +275,9 @@ 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`:
|
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
||||||
|
phone in landscape keeps the mobile layout and only tablet-class windows get
|
||||||
|
the sidebar):
|
||||||
|
|
||||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
for *every* tab (previously only Files had shell-level top chrome, with
|
for *every* tab (previously only Files had shell-level top chrome, with
|
||||||
@@ -284,12 +286,34 @@ bare system prompt gives no context on its own.
|
|||||||
other tab has a create/upload flow), and the account avatar; Android
|
other tab has a create/upload flow), and the account avatar; Android
|
||||||
gets a compact title row with `search`/avatar actions, relying on an
|
gets a compact title row with `search`/avatar actions, relying on an
|
||||||
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
||||||
icon for upload. `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
|
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
|
||||||
the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
|
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
||||||
|
tab) and each tab plants its own as the first sliver in its own
|
||||||
|
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
||||||
|
wrapping it in the framework's `SliverFloatingHeader`) instead of passing
|
||||||
|
it to `Scaffold.appBar` - see that file's doc comment for why (Material's
|
||||||
|
native "floating" app bar behavior, tied to that tab's own
|
||||||
|
`ScrollController`: scrolls away as the list scrolls down and reappears
|
||||||
|
the moment the drag reverses, not only once scrolled back to the top).
|
||||||
|
`topBar` is null (no top bar rendered) on desktop and while picking,
|
||||||
|
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
|
||||||
|
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
|
||||||
|
in-content header (the sort/filter controls row, or Files/Photos'
|
||||||
|
selection bar - see below); the two float/scroll independently - each
|
||||||
|
tab also wraps its whole `CustomScrollView` in `SafeArea(top: true,
|
||||||
|
bottom: false, ...)` so that pinned header stays clear of the status bar
|
||||||
|
once the floating top bar above it fully collapses (see
|
||||||
|
`topBarSliver`'s own doc comment for why that reservation can't live
|
||||||
|
inside the top bar itself). `BottomNavBar` (`widgets/bottom_nav_bar.dart`)
|
||||||
|
adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
|
||||||
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
|
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
|
||||||
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
|
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
|
||||||
link (opens Settings - there's no in-page anchor to scroll to its Tabs
|
link (opens Settings - there's no in-page anchor to scroll to its Tabs
|
||||||
section yet).
|
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||||
|
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||||
|
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||||
|
instead, a dropdown holding the same hidden-tabs + Settings content -
|
||||||
|
see `styling.md`'s Gotchas for the wiring.
|
||||||
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
||||||
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
||||||
(tab title, search, an "Upload" action on Files/Photos) over the same
|
(tab title, search, an "Upload" action on Files/Photos) over the same
|
||||||
|
|||||||
@@ -109,10 +109,18 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
- An extended pill: 56px tall, accent fill, labelled "Upload".
|
- An extended pill: 56px tall, accent fill, labelled "Upload".
|
||||||
- Sits 16px from the right and bottom edges of the content area.
|
- Sits 16px from the right and bottom edges of the content area.
|
||||||
- Stays mounted on every tab; it's only the extended label that's tied to
|
- Stays mounted on every tab; it's only the extended label that's tied to
|
||||||
Files/Photos. Elsewhere it collapses to an icon-only 56px circle (still
|
Files/Photos. Elsewhere it collapses to an icon-only 56px circle or
|
||||||
tappable - it always targets the Files tab's current folder), animating
|
rounded square (see below) - still tappable, it always targets the Files
|
||||||
the width/label change rather than the button popping fully in/out as
|
tab's current folder - animating the width/label change rather than the
|
||||||
the Scaffold's default FAB transition would on every tab switch.
|
button popping fully in/out as the Scaffold's default FAB transition
|
||||||
|
would on every tab switch.
|
||||||
|
- Corner radius follows the bottom bar style (Settings → Appearance):
|
||||||
|
fully round (`pill`, 999) when the bar is floating, matching that mode's
|
||||||
|
other already-rounder elements (the bar itself at 28px, its search
|
||||||
|
satellite a full circle); a rounded square (`card`, 20) when the bar is
|
||||||
|
attached, to suit that mode's flatter, edge-to-edge corners. Same radius
|
||||||
|
whether the FAB is collapsed to a circle/square or expanded to the full
|
||||||
|
pill shape.
|
||||||
|
|
||||||
**Chip**
|
**Chip**
|
||||||
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
|
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
|
||||||
@@ -162,6 +170,7 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
|
|||||||
**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; Favorite is 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.
|
||||||
- Swipe actions are set in Settings → Swipe on a file.
|
- Swipe actions are set in Settings → Swipe on a file.
|
||||||
|
|
||||||
**Selection action bar** (Files, Photos, Favorites - anywhere with
|
**Selection action bar** (Files, Photos, Favorites - anywhere with
|
||||||
@@ -271,9 +280,13 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
|
|||||||
| Element | iOS | Android |
|
| Element | iOS | Android |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 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, `search`, avatar |
|
| 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. |
|
||||||
| Upload | `plus` in the nav bar | Extended FAB |
|
| Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. Product UI otherwise stays flat (see 1.4), but this is the one other place - besides desktop dialogs - that gets the app's one shadow: a 1px `line` border plus the dialog shadow, since a bar that's genuinely floating above scrolling content needs to read as elevated. Row height drops (iOS 50, same as attached; Android 64 vs 80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (25 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it - which means every tab's scrollable list has to pad its own bottom enough to clear the bar's footprint, since nothing does that for it automatically once the body draws behind the bar. The Android FAB stays fully round in this mode (see "Upload" below), consistent with the bar's own rounder shape. |
|
||||||
|
| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. |
|
||||||
|
| Search (top bar, when not in the bottom bar) | A real inline search field (`NooSearchField`, non-editable launcher) below the large title | A search-field-styled launcher takes the title's own flexible slot instead of a bare `search` icon beside it - no second row to put it under the way iOS has |
|
||||||
|
| Navigation menu (Settings → Appearance → "Navigation menu", both platforms) | `drawer` (default): the `menu` icon opens the left `Drawer` (hidden tabs + Settings), unchanged. `avatarMenu`: no `menu` icon at all - the avatar button opens a dropdown instead (`showAvatarMenu`), anchored under the top bar, full width minus the same 16px gutter every content row uses, with the same hidden-tabs + Settings content the drawer holds (not its storage bar - this is a quick menu, not the drawer's full account summary). The avatar sits in the exact same (right-end) position either way, and the dropdown's own header repeats it there too, so the open menu reads as having grown out of the button that opened it. Not a mandatory replacement: this halves the top-corner surface a thumb has to reach (from `menu` + avatar down to avatar alone) without costing any permanent screen width the way an earlier side-rail exploration would have. |
|
||||||
|
| Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
|
||||||
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
|
| 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" |
|
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
|
||||||
@@ -356,15 +369,23 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
|
|||||||
slots, the rest sit behind "More". See §2 "Selection action bar".
|
slots, the rest sit behind "More". See §2 "Selection action bar".
|
||||||
9. Swipe on a file
|
9. Swipe on a file
|
||||||
|
|
||||||
Mobile uses one column of grouped lists, each individually collapsible
|
Mobile is a two-level menu, the way native iOS/Android Settings apps
|
||||||
(tap its label, expanded by default - `NooGroupedList`'s `collapsible`
|
work: the account card stays pinned at the top of a single top-level
|
||||||
param) so the full list can be collapsed down instead of needing a
|
list, and every other section (2-9 above) becomes one tappable
|
||||||
separate way to navigate it; an earlier version had a trailing jump rail
|
`NooSettingsRow` - icon, title, chevron - in a `NooGroupedList` below it.
|
||||||
(one small icon per section, pinned where the scrollbar would sit)
|
Tapping a row pushes a new screen (`NooTopBar`/`NooTopBarBack`) holding
|
||||||
instead, dropped for adding a second, redundant navigation method without
|
just that section's own content full-screen, so no page is ever more
|
||||||
shortening the page. Desktop uses a 2-column grid of cards with a 1px
|
than one category deep and no section needs to be individually
|
||||||
line and radius 20, wide enough to see most sections without scrolling,
|
collapsible any more. Two earlier designs were tried and dropped: a
|
||||||
so it gets neither.
|
trailing jump rail (one small icon per section, pinned where the
|
||||||
|
scrollbar would sit), and - after that - one long column of every
|
||||||
|
section inline, each individually collapsible (`NooGroupedList`'s
|
||||||
|
`collapsible` param) so the page could at least be collapsed down. Both
|
||||||
|
scrolled the *same* page to or past an anchor; a genuinely separate
|
||||||
|
pushed screen per category removes the scroll-depth problem outright
|
||||||
|
instead of just working around it. Desktop is unchanged: a 2-column grid
|
||||||
|
of cards with a 1px line and radius 20, wide enough to see most sections
|
||||||
|
without scrolling, so it gets neither a menu nor collapsing.
|
||||||
- **Share sheet / dialog:** sections in this order:
|
- **Share sheet / dialog:** sections in this order:
|
||||||
1. Header: file tile, name, size · folder, and close.
|
1. Header: file tile, name, size · folder, and close.
|
||||||
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
|
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
|
||||||
|
|||||||
@@ -801,3 +801,13 @@ Flutter's own default to already be high enough.
|
|||||||
`isRestoringSession` still gates the splash screen until the above resolves
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— see `standards.md` for why widget tests must mock both storage channels
|
||||||
rather than relying on this async path throwing naturally.
|
rather than relying on this async path throwing naturally.
|
||||||
|
|
||||||
|
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
|
||||||
|
|
||||||
|
## Changing a share's permissions
|
||||||
|
|
||||||
|
`NextcloudService.updateSharePermissions` does `PUT
|
||||||
|
/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` with a `permissions`
|
||||||
|
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||||
|
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
|
||||||
|
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
|
||||||
|
|||||||
@@ -90,32 +90,6 @@ class/method already makes obvious.
|
|||||||
`Align`).
|
`Align`).
|
||||||
- Run with `flutter test`.
|
- Run with `flutter test`.
|
||||||
|
|
||||||
## Play Store screenshots
|
|
||||||
|
|
||||||
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
|
|
||||||
listing screenshots **without any real data**: it runs the real, unmodified app
|
|
||||||
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
|
|
||||||
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
|
|
||||||
it with `integration_test/store_screenshots_test.dart` (host-side driver:
|
|
||||||
`test_driver/integration_test.dart`, which saves `adb screencap` images).
|
|
||||||
Output lands in `store_listing/screenshots/` (gitignored) after
|
|
||||||
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
|
|
||||||
limits (each side 320-3840 px, long side at most 2x the short side - a stock
|
|
||||||
1080x2400 phone screen is 2.22:1 and would be rejected).
|
|
||||||
|
|
||||||
- The test logs in by writing the demo user's account/app password into
|
|
||||||
storage via `AccountStore` before calling `main()`, so it skips the browser
|
|
||||||
login flow; it also fixes the theme (light, non-dynamic colour), the visible
|
|
||||||
tabs and the first-run notification prompt so shots are reproducible.
|
|
||||||
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
|
|
||||||
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
|
|
||||||
`FilesView`s, and the fake content's names (defined in
|
|
||||||
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
|
|
||||||
- `flutter test` only runs `test/`, so this never runs as part of the normal
|
|
||||||
suite; `integration_test` is a dev-only dependency.
|
|
||||||
- Always review the images before uploading - see
|
|
||||||
`tool/demo_server/README.md` for the known places real data could appear.
|
|
||||||
|
|
||||||
## Local install/deploy
|
## Local install/deploy
|
||||||
|
|
||||||
Never use `flutter install` to push a build to a test device — it always
|
Never use `flutter install` to push a build to a test device — it always
|
||||||
@@ -157,3 +131,26 @@ Networking is deliberately split: `package:http` for simple JSON/XML
|
|||||||
request-response calls, `package:dio` only where streaming/progress is
|
request-response calls, `package:dio` only where streaming/progress is
|
||||||
needed (downloads). Don't introduce a third HTTP client — extend the
|
needed (downloads). Don't introduce a third HTTP client — extend the
|
||||||
existing split instead.
|
existing split instead.
|
||||||
|
|
||||||
|
## Play Store upload (CI)
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
|
||||||
|
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
|
||||||
|
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
|
||||||
|
`internal` track with `status: completed`, so it rolls out to internal
|
||||||
|
testers automatically (promote to other tracks in Play Console). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||||
|
repo secret (service account with release permissions; the app's first
|
||||||
|
release must be uploaded manually). Play rejects a repeated `versionCode`, so
|
||||||
|
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
|
||||||
|
will fail the upload. Release notes come from
|
||||||
|
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
|
||||||
|
|
||||||
|
## Community PR flow
|
||||||
|
|
||||||
|
Gitea is the source of truth; GitHub is a push mirror (never commit to GitHub
|
||||||
|
`main`) and the public issue tracker. `scripts/sync-github-prs.sh` (cron on the
|
||||||
|
home server) imports open GitHub PRs into Gitea via Agit
|
||||||
|
(`refs/for/main`, topic `gh-pr-N`), where `.gitea/workflows/pr.yml` runs
|
||||||
|
analyze + tests. That workflow must never use secrets; signing and Play
|
||||||
|
credentials stay on the tag-triggered `build.yml`/`release.yml`. Issue
|
||||||
|
templates are in `.github/ISSUE_TEMPLATE/`.
|
||||||
|
|||||||
+132
-11
@@ -30,10 +30,12 @@ widgets. Key points:
|
|||||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||||
- **Font**: Inter via `google_fonts`, applied through
|
- **Font**: Inter via `google_fonts`, applied through
|
||||||
`GoogleFonts.interTextTheme(...)`. In tests, set
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
|
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
|
||||||
indefinitely (see `standards.md`).
|
app never contacts Google (the privacy policy on the website relies on
|
||||||
|
this — keep it true; add new weights as bundled files). Tests set the
|
||||||
|
same flag in `setUpAll` (see `standards.md`).
|
||||||
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
||||||
`surfaceContainerLow`.
|
`surfaceContainerLow`.
|
||||||
- **App bars**: flat, not centered, `surface` background.
|
- **App bars**: flat, not centered, `surface` background.
|
||||||
@@ -78,10 +80,10 @@ Catalog:
|
|||||||
| Folder | Components |
|
| Folder | Components |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `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` (desktop), `NooSwipeAction` |
|
||||||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` |
|
| `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` |
|
||||||
|
|
||||||
Gotchas:
|
Gotchas:
|
||||||
@@ -90,10 +92,16 @@ Gotchas:
|
|||||||
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
|
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
|
||||||
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
|
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
|
||||||
do). Its `collapsible`/`initiallyExpanded` params (off by default) make
|
do). Its `collapsible`/`initiallyExpanded` params (off by default) make
|
||||||
`label` a tap target that shows/hides the card - `SettingsSection` is the
|
`label` a tap target that shows/hides the card - no current caller opts
|
||||||
only caller that opts in, for Settings' mobile sections.
|
in (Settings' mobile sections used to, when every section rendered
|
||||||
- `NooSwipeAction` only reveals its action. The user has to tap the block to
|
inline in one long column; now each section is its own pushed screen -
|
||||||
trigger it; a full swipe never deletes.
|
see `account_view.dart`'s doc comment - so there's nothing left to
|
||||||
|
collapse). The params stay on the component itself since it's otherwise
|
||||||
|
generic.
|
||||||
|
- `NooSwipeAction` reveals its action on a normal swipe (tap the block to
|
||||||
|
trigger it) but also fires it directly if the drag goes far enough (past
|
||||||
|
1.8x the block's width) before release - a full swipe-through does delete
|
||||||
|
in one gesture, it's not tap-only anymore.
|
||||||
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
|
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
|
||||||
built yet. `NooSidebar.windowControls` is the slot for it.
|
built yet. `NooSidebar.windowControls` is the slot for it.
|
||||||
- Always read colors through `context.nooColors`, never
|
- Always read colors through `context.nooColors`, never
|
||||||
@@ -137,6 +145,12 @@ blocks are noted where they matter:
|
|||||||
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
|
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
|
||||||
`tab_location.dart`) — the loading/error/empty-state slivers and
|
`tab_location.dart`) — the loading/error/empty-state slivers and
|
||||||
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
|
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
|
||||||
|
`tab_state_slivers.dart` also has `topBarSliver`, which every regular
|
||||||
|
tab's view uses to plant its own `AppTopBar` instance as the first sliver
|
||||||
|
in its `CustomScrollView` (a thin wrapper around the framework's
|
||||||
|
`SliverFloatingHeader`) instead of the shell passing one shared instance
|
||||||
|
to `Scaffold.appBar` - see its doc comment and `architecture.md`'s
|
||||||
|
"Mobile" bullet for why.
|
||||||
`lib/widgets/settings/` — Settings' 8 section widgets plus
|
`lib/widgets/settings/` — Settings' 8 section widgets plus
|
||||||
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
|
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
|
||||||
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
||||||
@@ -184,6 +198,27 @@ blocks are noted where they matter:
|
|||||||
bars and video transport controls (its other former user, the floating
|
bars and video transport controls (its other former user, the floating
|
||||||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||||
overlay instead of building a new blur/shadow combo.
|
overlay instead of building a new blur/shadow combo.
|
||||||
|
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
|
||||||
|
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
|
||||||
|
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
|
||||||
|
expanded always labelled. Phone/Android layout only.
|
||||||
|
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
|
||||||
|
out past `viewport.height / doc.height`, so for short documents (<= 3
|
||||||
|
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
|
||||||
|
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
|
||||||
|
the boundary height reaches the viewport and fit-width stays reachable
|
||||||
|
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
|
||||||
|
+ top bar height, 0 when the controls are hidden) animates the viewport
|
||||||
|
below the overlaid top bar.
|
||||||
|
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
|
||||||
|
under the label, above the card; `SettingsSection` uses it for its
|
||||||
|
`notice` on phones.
|
||||||
|
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
|
||||||
|
the media viewer's bottom bar as a draggable panel (mobile/phone only;
|
||||||
|
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
|
||||||
|
Details button, or swiping up on swipeable media (raw `Listener` in
|
||||||
|
`file_viewer_screen.dart`) grows it upward so the action row rides on top
|
||||||
|
of the details; tapping the media collapses it.
|
||||||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||||
@@ -191,7 +226,69 @@ blocks are noted where they matter:
|
|||||||
opacity/blur knob - flat surfaces per the design system), grid tiles are
|
opacity/blur knob - flat surfaces per the design system), grid tiles are
|
||||||
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
|
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
|
||||||
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
|
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
|
||||||
instead of a corner badge.
|
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||||||
|
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||||||
|
don't confuse the two: this one is still non-blurred, just inset with a
|
||||||
|
`line` border and `nooDialogShadow` (the app's one other shadow user - see
|
||||||
|
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||||||
|
either). The host `Scaffold` needs `extendBody: true` while it's active
|
||||||
|
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||||
|
which also means every tab's own scrollable list has to reserve enough
|
||||||
|
bottom padding to clear the bar - nothing does that automatically once the
|
||||||
|
body draws behind it. Use `bottomBarClearance(context)`
|
||||||
|
(`tab_state_slivers.dart`) for that rather than a flat `100`; it already
|
||||||
|
accounts for both bar styles.
|
||||||
|
Settings' separate "Search in bottom bar" toggle
|
||||||
|
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search
|
||||||
|
entry to either bar style (`NooBottomBar`'s `searchDestination`/
|
||||||
|
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||||
|
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||||
|
anywhere that needs the *current* cap on regular tabs.
|
||||||
|
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
||||||
|
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
||||||
|
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
||||||
|
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
||||||
|
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
||||||
|
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
||||||
|
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||||
|
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||||
|
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||||
|
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
||||||
|
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
||||||
|
the drawer. The menu header has a chevron that expands an account section
|
||||||
|
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
||||||
|
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
||||||
|
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||||
|
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||||
|
not a modal flow) - there's no existing anchored-popup primitive here
|
||||||
|
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||||||
|
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||||||
|
like this. It's positioned just past the status bar (`SafeArea`'s own
|
||||||
|
inset, not the top bar's full height on top of that) so it covers the
|
||||||
|
top bar - including the tab title - rather than sitting below it, and
|
||||||
|
its card carries two stacked `boxShadow`s rather than just
|
||||||
|
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
||||||
|
to read as basically invisible on a small card over a dark theme's
|
||||||
|
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
||||||
|
edge to be visible against an already-dark backdrop), so a second,
|
||||||
|
tighter, more opaque contact shadow underneath it gives real elevation
|
||||||
|
in both themes. The card's border can go missing wherever an opaque row
|
||||||
|
sits against it too (every corner but the header's, which has no
|
||||||
|
full-bleed fill of its own) if a `Container` combines `border` with its
|
||||||
|
own `clipBehavior` - that paints the border as part of the *outer*
|
||||||
|
decoration, then the clipped child on top right up to the same boundary,
|
||||||
|
with no gap for the border's own stroke to show through. Fixed the same
|
||||||
|
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
||||||
|
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
||||||
|
reduced by that same 1px) around the filled, clipped content instead, so
|
||||||
|
it never paints over the border. Also added
|
||||||
|
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||||||
|
second search row under the way iOS's `search:` slot does, so an inline
|
||||||
|
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||||||
|
search isn't in the bottom bar) sits to the title's own right in that
|
||||||
|
flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/
|
||||||
|
ellipsizes if there's truly no room but doesn't claim more than it needs
|
||||||
|
otherwise - instead of the title being replaced by it.
|
||||||
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||||
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
||||||
chip and pull-to-refresh gesture/spinner. Every screen (including
|
chip and pull-to-refresh gesture/spinner. Every screen (including
|
||||||
@@ -254,3 +351,27 @@ blocks are noted where they matter:
|
|||||||
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
||||||
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
||||||
layer), so it renders at a sensible size at 72-80px.
|
layer), so it renders at a sensible size at 72-80px.
|
||||||
|
|
||||||
|
## Text/markdown viewer
|
||||||
|
|
||||||
|
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
|
||||||
|
|
||||||
|
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
|
||||||
|
|
||||||
|
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
|
||||||
|
|
||||||
|
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
|
||||||
|
|
||||||
|
## Desktop/tablet layout notes
|
||||||
|
|
||||||
|
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
||||||
|
system bars clear of the sidebar; the sidebar has no divider between pinned
|
||||||
|
and hidden tabs.
|
||||||
|
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
||||||
|
pushed category screens render the desktop card layout.
|
||||||
|
- Settings that don't apply in wide-tablet (Appearance nav options,
|
||||||
|
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
||||||
|
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
||||||
|
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
||||||
|
width, never below the phone count), so a tablet in portrait gets more
|
||||||
|
columns rather than a few huge tiles.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: Check PR
|
||||||
|
|
||||||
|
# Runs analyze + tests on pull requests. Deliberately uses no secrets, so it
|
||||||
|
# is safe against imported contributor code. Keep it that way: signing keys
|
||||||
|
# and the Play credentials are only available to the tag-triggered workflows.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: stable
|
||||||
|
|
||||||
|
- run: flutter pub get
|
||||||
|
- run: flutter analyze
|
||||||
|
- run: flutter test
|
||||||
@@ -39,6 +39,16 @@ jobs:
|
|||||||
# platform/build-tools versions it actually needs on its own.
|
# platform/build-tools versions it actually needs on its own.
|
||||||
packages: "platform-tools"
|
packages: "platform-tools"
|
||||||
|
|
||||||
|
- name: Check tag matches pubspec version
|
||||||
|
# Play rejects a repeated versionCode and a mismatched tag would
|
||||||
|
# publish the wrong version name, so fail fast before building.
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
|
||||||
|
if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then
|
||||||
|
echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
@@ -67,6 +77,7 @@ jobs:
|
|||||||
mkdir -p release
|
mkdir -p release
|
||||||
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
|
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
|
||||||
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
|
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
|
||||||
|
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Publish Gitea release with App Bundle attached
|
- name: Publish Gitea release with App Bundle attached
|
||||||
# Gitea-native release action (not GitHub's) - authenticates and
|
# Gitea-native release action (not GitHub's) - authenticates and
|
||||||
@@ -78,26 +89,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ${{ env.AAB_PATH }}
|
files: ${{ env.AAB_PATH }}
|
||||||
|
|
||||||
- name: Decode Play Console service account key
|
- name: Upload to Google Play (internal track, auto-publish)
|
||||||
# Skipped (rather than failing the whole run) until the secret is
|
# Rolls the build out to internal testers automatically
|
||||||
# set up - see tool/upload_play_bundle.py's doc comment for what
|
# (`status: completed`); promote it to other tracks in Play Console.
|
||||||
# that setup involves and its one hard prerequisite: Google won't
|
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
|
||||||
# accept API calls for an app that's never had a single manual
|
# first release must already have been uploaded by hand.
|
||||||
# upload through the Play Console web UI.
|
uses: r0adkll/upload-google-play@v1
|
||||||
if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }}
|
with:
|
||||||
run: echo "$SERVICE_ACCOUNT_JSON_BASE64" | base64 -d > "${{ runner.temp }}/play-service-account.json"
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
env:
|
packageName: dev.ayushya.noo
|
||||||
SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
|
releaseFiles: ${{ env.AAB_PATH }}
|
||||||
|
releaseName: ${{ env.VERSION }}
|
||||||
- name: Upload App Bundle to Play Console library
|
track: internal
|
||||||
# Deliberately doesn't touch any release track - see
|
status: completed
|
||||||
# tool/upload_play_bundle.py. Uploaded bundles show up under
|
whatsNewDirectory: distribution/whatsnew
|
||||||
# Release > App bundle explorer in Play Console, ready to promote
|
|
||||||
# to a track by hand whenever it's actually time to ship.
|
|
||||||
if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }}
|
|
||||||
run: |
|
|
||||||
pip install --quiet google-api-python-client google-auth
|
|
||||||
python3 tool/upload_play_bundle.py "${{ env.AAB_PATH }}"
|
|
||||||
env:
|
|
||||||
GOOGLE_PLAY_PACKAGE_NAME: dev.ayushya.noo
|
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: ${{ runner.temp }}/play-service-account.json
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something isn't working as expected
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Noo version
|
||||||
|
description: Settings → About
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device and OS version
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server
|
||||||
|
attributes:
|
||||||
|
label: Nextcloud server version
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Screenshots or logs
|
||||||
|
description: Remove any personal data or server addresses you don't want public.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security problem
|
||||||
|
url: https://github.com/ayushyamitabh/noo/blob/main/SECURITY.md
|
||||||
|
about: Please report security issues privately, not as public issues.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an improvement
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: >
|
||||||
|
Noo aims to be a simple, just-works Nextcloud client with reasonable
|
||||||
|
customization, not a replacement for every Nextcloud feature. Requests
|
||||||
|
that fit that goal are most likely to be accepted.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What problem are you trying to solve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: What would you like to see?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this change, and why? Link the issue it closes, e.g. "Closes #123". -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Screenshots / recordings
|
||||||
|
|
||||||
|
<!-- Required for UI changes: before/after, light + dark, phone + tablet width where relevant. Delete if not applicable. -->
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- How did you verify this? Device/emulator and OS version for anything platform-specific. -->
|
||||||
|
|
||||||
|
- [ ] `flutter analyze` is clean
|
||||||
|
- [ ] `flutter test` passes
|
||||||
|
- [ ] Tried it on a device or emulator (UI / platform changes)
|
||||||
|
- [ ] Tested against a real Nextcloud server (server / sync changes)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] New or changed behavior has tests where practical
|
||||||
|
- [ ] Docs in `.claude/context/` updated if architecture, server integration, styling, or standards changed
|
||||||
|
- [ ] No secrets, server URLs, credentials, or personal data in code, tests, or screenshots
|
||||||
|
- [ ] Commits follow the commit message template (`.gitmessage`)
|
||||||
+11
-3
@@ -45,6 +45,14 @@ app.*.map.json
|
|||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
# Play Store screenshot tooling (tool/screenshots.sh)
|
# Local Claude Code state
|
||||||
/store_listing/screenshots/
|
.claude/worktrees/
|
||||||
/tool/demo_server/.venv/
|
.claude/settings.local.json
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Signing / secrets (never commit)
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
key.properties
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# <Summary: imperative, sentence case, no trailing period, <= 72 chars>
|
||||||
|
#
|
||||||
|
# Why is this change needed? What does it do? Wrap lines at 72 chars.
|
||||||
|
# Explain the "why"; the diff already shows the "what".
|
||||||
|
#
|
||||||
|
# Examples of good summaries:
|
||||||
|
# Fix PDF zoom not returning to fit-width
|
||||||
|
# Add Upload button size setting
|
||||||
|
# Bump to 1.0.2+5
|
||||||
|
#
|
||||||
|
# Closes #<issue>
|
||||||
|
#
|
||||||
|
# Trailers (keep the ones that apply):
|
||||||
|
# Co-Authored-By: Name <email>
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Lines starting with '#' are ignored. Enable this template once with:
|
||||||
|
# git config commit.template .gitmessage
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Contributing to Noo
|
||||||
|
|
||||||
|
Thanks for your interest in improving Noo. Bug reports, ideas, and pull
|
||||||
|
requests are welcome.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- For anything bigger than a small fix, open an issue first so we can agree on
|
||||||
|
the approach before you spend time on it.
|
||||||
|
- Read [`CLAUDE.md`](CLAUDE.md) and the topic files in
|
||||||
|
[`.claude/context/`](.claude/context): architecture, server integration,
|
||||||
|
styling, and code standards. They apply to human and AI contributors alike.
|
||||||
|
- Never include real server URLs, usernames, app passwords, or personal files
|
||||||
|
in issues, tests, or screenshots.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
You need a Nextcloud server to sign in to. A throwaway local instance is best
|
||||||
|
for testing anything destructive (delete, move, trash).
|
||||||
|
|
||||||
|
## Making a change
|
||||||
|
|
||||||
|
1. Branch from `main`.
|
||||||
|
2. Keep the change focused; unrelated cleanup belongs in its own PR.
|
||||||
|
3. Run the checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Format only the files you touched (`dart format <files>`), not the whole
|
||||||
|
tree.
|
||||||
|
5. If you changed architecture, server integration, styling conventions, or
|
||||||
|
standards, update the matching file in `.claude/context/` in the same PR.
|
||||||
|
6. Open a pull request and fill in the template.
|
||||||
|
|
||||||
|
## How PRs and issues are handled
|
||||||
|
|
||||||
|
Issues and pull requests live on GitHub. The maintainer's source of truth is
|
||||||
|
a self-hosted Gitea instance: open PRs are imported there, reviewed, and
|
||||||
|
merged there, and `main` is then mirrored back to GitHub. Two things follow:
|
||||||
|
|
||||||
|
- Keep discussion on the GitHub PR; that is where I'll reply.
|
||||||
|
- A merged change may show up on GitHub as the PR being closed with a link to
|
||||||
|
the commit rather than as "Merged". Your authorship is preserved.
|
||||||
|
- Imported PRs run `flutter analyze` and `flutter test` on my server, so
|
||||||
|
please run both locally first.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Use the repository's template so history stays consistent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config commit.template .gitmessage
|
||||||
|
```
|
||||||
|
|
||||||
|
Write an imperative, sentence-case summary of 72 characters or fewer, then a
|
||||||
|
blank line and a short explanation of *why* when it isn't obvious.
|
||||||
|
|
||||||
|
## License of contributions
|
||||||
|
|
||||||
|
Noo is licensed under the [PolyForm Shield License 1.0.0](LICENSE). By
|
||||||
|
submitting a contribution you confirm that you wrote it (or have the right to
|
||||||
|
submit it), and you license it to the maintainer and everyone who receives the
|
||||||
|
project under those same terms. You also grant the maintainer the right to
|
||||||
|
relicense the project, including your contribution, in the future.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Please do not file security problems as public issues. Email
|
||||||
|
[hello@ayushya.dev](mailto:hello@ayushya.dev) with the details and I'll
|
||||||
|
respond as soon as I can.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
Required Notice: Copyright (c) 2026 Ayushya (https://ayushya.dev)
|
||||||
|
|
||||||
|
# PolyForm Shield License 1.0.0
|
||||||
|
|
||||||
|
<https://polyformproject.org/licenses/shield/1.0.0>
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||||
|
|
||||||
|
## Copyright License
|
||||||
|
|
||||||
|
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Distribution License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Notices
|
||||||
|
|
||||||
|
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||||
|
|
||||||
|
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||||
|
|
||||||
|
## Changes and New Works License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||||
|
|
||||||
|
## Patent License
|
||||||
|
|
||||||
|
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||||
|
|
||||||
|
## Noncompete
|
||||||
|
|
||||||
|
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
||||||
|
|
||||||
|
## Competition
|
||||||
|
|
||||||
|
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
||||||
|
|
||||||
|
## New Products
|
||||||
|
|
||||||
|
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
||||||
|
|
||||||
|
## Discontinued Products
|
||||||
|
|
||||||
|
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
||||||
|
|
||||||
|
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
||||||
|
|
||||||
|
## Sales of Business
|
||||||
|
|
||||||
|
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
||||||
|
|
||||||
|
## Fair Use
|
||||||
|
|
||||||
|
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||||
|
|
||||||
|
## No Other Rights
|
||||||
|
|
||||||
|
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||||
|
|
||||||
|
## Patent Defense
|
||||||
|
|
||||||
|
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||||
|
|
||||||
|
## Violations
|
||||||
|
|
||||||
|
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||||
|
|
||||||
|
## No Liability
|
||||||
|
|
||||||
|
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||||
|
|
||||||
|
A **product** can be a good or service, or a combination of them.
|
||||||
|
|
||||||
|
**You** refers to the individual or entity agreeing to these terms.
|
||||||
|
|
||||||
|
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
||||||
|
|
||||||
|
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
||||||
|
|
||||||
|
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||||
|
|
||||||
|
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||||
|
|
||||||
|
**Use** means anything you do with the software requiring one of your licenses.
|
||||||
@@ -1,36 +1,46 @@
|
|||||||
# Noo
|
# Noo
|
||||||
|
|
||||||
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
|
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
|
||||||
and Material You.
|
and Material 3.
|
||||||
|
|
||||||
Noo gives you a clean, modern way to browse and manage the files on your own
|
Noo is a clean, modern way to browse and manage the files on your own
|
||||||
Nextcloud server — files, photos, shares, activity, and trash, all in one
|
Nextcloud server: files, photos, shares, activity, and trash in one app,
|
||||||
app, themed to match your device.
|
themed to match your device and laid out for both phones and tablets.
|
||||||
|
|
||||||
|
Website: <https://noo.ayushya.dev>
|
||||||
|
|
||||||
|
> Noo is an independent, unofficial client. It is not affiliated with or
|
||||||
|
> endorsed by Nextcloud GmbH.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files** — browse, upload, move, rename, delete, and favorite files and
|
- **Files**: browse, upload, move, copy, rename, delete, and favorite files
|
||||||
folders; sort and filter per folder
|
and folders; sort and filter per folder; list or grid view
|
||||||
- **Photos** — a dedicated media gallery with a full-screen viewer for images
|
- **Photos**: a media gallery with a full-screen viewer for images and video
|
||||||
and video
|
- **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
|
||||||
- **Activity** — a feed of what's changed on your server
|
pull-up details panel (info, activity, sharing)
|
||||||
- **Shares** — see and manage what you've shared and what's shared with you
|
- **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
|
||||||
- **Trash** — restore or permanently delete recently removed items
|
have shared, what changed, and what you deleted
|
||||||
- **Material You theming** — dynamic color on supported devices, five
|
- **Search** across your server
|
||||||
built-in accent colors, light/dark/AMOLED
|
- **Offline and sync**: keep files available offline and sync chosen folders
|
||||||
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign
|
in the background
|
||||||
in through your browser, and Noo never sees or stores your password,
|
- **Multiple accounts**: switch between saved accounts from the avatar menu
|
||||||
only a scoped app token
|
- **App lock**: optionally require device authentication to open the app,
|
||||||
|
switch accounts, or reveal hidden files
|
||||||
## Screenshots
|
- **Share to Noo**: upload from any app's share sheet
|
||||||
|
- **Make it yours**: Material You dynamic color, five accent colors,
|
||||||
_Coming soon._
|
light/dark/AMOLED, attached or floating bottom bar, hamburger or avatar
|
||||||
|
navigation, adjustable upload button, and a tablet layout with a sidebar
|
||||||
|
- **Secure sign-in**: authenticates through Nextcloud's Login Flow v2. You
|
||||||
|
sign in through your browser, and Noo never sees or stores your password,
|
||||||
|
only a scoped app password
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
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
|
||||||
- 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)
|
||||||
|
|
||||||
@@ -39,38 +49,63 @@ flutter pub get
|
|||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
On first launch, enter your server's address — Noo opens your browser to
|
On first launch, enter your server's address. Noo opens your browser to
|
||||||
finish signing in.
|
finish signing in.
|
||||||
|
|
||||||
## Supported platforms
|
|
||||||
|
|
||||||
Android, iOS, and Windows.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter build apk # Android
|
flutter build apk # Android (debug-signed unless you configure a key)
|
||||||
flutter build ios # iOS
|
flutter build ios # iOS
|
||||||
flutter build windows # Windows
|
flutter build windows # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Release builds read signing details from `android/key.properties` or from
|
||||||
|
`RELEASE_KEYSTORE_*` environment variables (see `android/app/build.gradle.kts`).
|
||||||
|
Neither the keystore nor `key.properties` is, or should ever be, committed.
|
||||||
|
Without them, release builds fall back to debug signing.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
||||||
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV
|
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
|
||||||
and OCS APIs — no bundled Nextcloud SDK.
|
OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
|
||||||
|
transfers and background sync.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Project context for contributors (human or AI) lives in
|
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||||
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server
|
first. In short:
|
||||||
integration, styling conventions, and code standards.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter test # run tests
|
flutter test # run tests
|
||||||
flutter analyze # static analysis / lints
|
flutter analyze # static analysis / lints
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Architecture, server integration, styling conventions, and code standards are
|
||||||
|
documented in [`CLAUDE.md`](CLAUDE.md) and `.claude/context/`, written for
|
||||||
|
both human and AI contributors.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Noo talks only to the Nextcloud server you point it at. It has no analytics
|
||||||
|
and no third-party tracking. Read the full [privacy policy](https://noo.ayushya.dev/privacy).
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Typefaces [Instrument Sans](https://fonts.google.com/specimen/Instrument+Sans)
|
||||||
|
and [Schibsted Grotesk](https://fonts.google.com/specimen/Schibsted+Grotesk)
|
||||||
|
are bundled under the SIL Open Font License, and icons are from
|
||||||
|
[Lucide](https://lucide.dev). Those keep their own licenses; the license
|
||||||
|
below covers Noo's own code and assets.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Not yet licensed for redistribution — license TBD.
|
Noo is source-available under the
|
||||||
|
[PolyForm Shield License 1.0.0](LICENSE). In plain terms: you may read, build,
|
||||||
|
run, and modify the code, and contribute changes back, but you may not use it
|
||||||
|
to make a product or service that competes with Noo. Please do not republish
|
||||||
|
it as your own app.
|
||||||
|
|
||||||
|
Contributions are welcome under the terms in
|
||||||
|
[`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
If you find a security problem in Noo, please email
|
||||||
|
[hello@ayushya.dev](mailto:hello@ayushya.dev) instead of opening a public
|
||||||
|
issue. Include steps to reproduce and the version (`pubspec.yaml`). I'll
|
||||||
|
acknowledge reports as soon as I can and credit you in the fix if you'd like.
|
||||||
|
|
||||||
|
Never include real server URLs, credentials, or personal files in a report.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
// Drives the real app through its tabs and states against the fake-data demo
|
|
||||||
// server and captures the Play Store screenshots. Not part of the normal test
|
|
||||||
// suite (`flutter test` only runs `test/`) - run it through
|
|
||||||
// `tool/screenshots.sh`, which starts the demo server, an emulator and the
|
|
||||||
// driver in `test_driver/integration_test.dart` that saves the images.
|
|
||||||
//
|
|
||||||
// The app starts already logged in: the demo user's credentials are written
|
|
||||||
// straight into storage before `main()` runs, so Login Flow v2's browser
|
|
||||||
// step is skipped.
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:integration_test/integration_test.dart';
|
|
||||||
import 'package:noo/main.dart' as app;
|
|
||||||
import 'package:noo/models/app_tab.dart';
|
|
||||||
import 'package:noo/models/saved_account.dart';
|
|
||||||
import 'package:noo/providers/files_controller.dart';
|
|
||||||
import 'package:noo/providers/settings_controller.dart';
|
|
||||||
import 'package:noo/providers/sync_status_controller.dart';
|
|
||||||
import 'package:noo/services/account_store.dart';
|
|
||||||
import 'package:noo/theme/design_tokens.dart';
|
|
||||||
import 'package:noo/widgets/bottom_nav_bar.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
const _serverUrl = String.fromEnvironment(
|
|
||||||
'DEMO_SERVER_URL',
|
|
||||||
defaultValue: 'http://localhost',
|
|
||||||
);
|
|
||||||
const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex');
|
|
||||||
const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD');
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'Play Store screenshots',
|
|
||||||
(tester) async {
|
|
||||||
expect(
|
|
||||||
_appPassword,
|
|
||||||
isNotEmpty,
|
|
||||||
reason: 'Pass the demo user\'s app password via '
|
|
||||||
'--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).',
|
|
||||||
);
|
|
||||||
await _seedLoggedInStorage();
|
|
||||||
|
|
||||||
app.main();
|
|
||||||
await tester.pump();
|
|
||||||
await _pumpUntil(tester, find.byType(app.MainShellView));
|
|
||||||
|
|
||||||
// Android only: required before `takeScreenshot()` can capture
|
|
||||||
// anything at all.
|
|
||||||
if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage();
|
|
||||||
|
|
||||||
Future<void> shoot(String name) async {
|
|
||||||
// Let scroll/transition animations and image fades finish first.
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
await binding.takeScreenshot(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 01 Files (list) ------------------------------------------------
|
|
||||||
final filesTab = find.byKey(const ValueKey('files'));
|
|
||||||
await _pumpUntil(
|
|
||||||
tester,
|
|
||||||
find.descendant(of: filesTab, matching: find.text('Documents')),
|
|
||||||
);
|
|
||||||
await shoot('01_Files');
|
|
||||||
|
|
||||||
// ---- 02 Files (grid, inside a folder of photos) --------------------
|
|
||||||
final files = _read<FilesController>(tester);
|
|
||||||
await files.navigateToAbsoluteFolder('/Trips');
|
|
||||||
await _pumpUntil(
|
|
||||||
tester,
|
|
||||||
find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')),
|
|
||||||
);
|
|
||||||
// The controls row scrolls horizontally and doesn't fit every control
|
|
||||||
// on a narrow screen, so "Grid view" can start off past the clipped
|
|
||||||
// edge - not just off the *current* tab (which `.hitTestable()` alone
|
|
||||||
// would handle), but off the *visible part of this tab's own row*.
|
|
||||||
// `_tapInTab` scrolls it into view first.
|
|
||||||
await _tapInTab(tester, filesTab, find.byTooltip('Grid view'));
|
|
||||||
await tester.pump(const Duration(seconds: 4)); // thumbnails
|
|
||||||
await shoot('02_Files_Grid');
|
|
||||||
|
|
||||||
// ---- 07 File viewer -------------------------------------------------
|
|
||||||
await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg'));
|
|
||||||
await tester.pump(const Duration(seconds: 4));
|
|
||||||
await shoot('07_Viewer');
|
|
||||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// Back to the root folder, list view.
|
|
||||||
await _tapInTab(tester, filesTab, find.byTooltip('List view'));
|
|
||||||
await files.navigateToAbsoluteFolder('/');
|
|
||||||
await tester.pump(const Duration(seconds: 2));
|
|
||||||
|
|
||||||
// ---- 03 Photos ------------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.photos);
|
|
||||||
await tester.pump(const Duration(seconds: 5)); // grid + thumbnails
|
|
||||||
await shoot('03_Photos');
|
|
||||||
|
|
||||||
// ---- 04 Favorites ---------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.favorites);
|
|
||||||
await _pumpUntil(tester, find.text('Project brief.pdf'));
|
|
||||||
await shoot('04_Favorites');
|
|
||||||
|
|
||||||
// ---- 05 Activity ----------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.activity);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('05_Activity');
|
|
||||||
|
|
||||||
// ---- 06 Shares ------------------------------------------------------
|
|
||||||
await _goToTab(tester, AppTab.shares);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('06_Shares');
|
|
||||||
|
|
||||||
// ---- 08 Sync status panel ------------------------------------------
|
|
||||||
// Sync two folders to the device; the header chip flips to "Synced"
|
|
||||||
// once the native worker has finished, and tapping it opens the panel.
|
|
||||||
await _goToTab(tester, AppTab.files);
|
|
||||||
await _read<SyncStatusController>(
|
|
||||||
tester,
|
|
||||||
).addSyncedPaths({'/Documents': true, '/Trips': true});
|
|
||||||
await _pumpUntil(tester, find.text('Synced'), timeout: 180);
|
|
||||||
await _tapInTab(tester, filesTab, find.text('Synced'));
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
await shoot('08_Sync_Status');
|
|
||||||
await _tapInTab(tester, filesTab, find.byTooltip('Collapse'));
|
|
||||||
|
|
||||||
// ---- 09 Offline -----------------------------------------------------
|
|
||||||
_setVisibleTabs(tester, [
|
|
||||||
AppTab.files,
|
|
||||||
AppTab.photos,
|
|
||||||
AppTab.favorites,
|
|
||||||
AppTab.shares,
|
|
||||||
AppTab.offline,
|
|
||||||
]);
|
|
||||||
await tester.pump(const Duration(milliseconds: 500));
|
|
||||||
await _goToTab(tester, AppTab.offline);
|
|
||||||
await _pumpUntil(
|
|
||||||
tester,
|
|
||||||
find.descendant(
|
|
||||||
of: find.byKey(const ValueKey('offline')),
|
|
||||||
matching: find.text('Documents'),
|
|
||||||
),
|
|
||||||
timeout: 120,
|
|
||||||
);
|
|
||||||
await shoot('09_Offline');
|
|
||||||
|
|
||||||
// ---- 10/11 Settings -------------------------------------------------
|
|
||||||
await _tap(tester, find.byTooltip('Settings').hitTestable().first);
|
|
||||||
await _pumpUntil(tester, find.text('Accounts'));
|
|
||||||
await shoot('10_Settings');
|
|
||||||
// Wait for it, rather than assuming it's already built - ensureVisible
|
|
||||||
// itself has no retry/timeout, so on any transient delay (e.g. still
|
|
||||||
// mid-route-transition) it fails immediately instead of giving the
|
|
||||||
// frame a moment to catch up.
|
|
||||||
await _pumpUntil(tester, find.text('Files Cache'));
|
|
||||||
await tester.ensureVisible(find.text('Files Cache'));
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await shoot('11_Settings_Sync');
|
|
||||||
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// ---- 12/13 Dark theme -------------------------------------------------
|
|
||||||
final settings = _read<SettingsController>(tester);
|
|
||||||
settings.setThemeMode(ThemeMode.dark);
|
|
||||||
await _goToTab(tester, AppTab.files);
|
|
||||||
await tester.pump(const Duration(seconds: 2));
|
|
||||||
await shoot('12_Files_Dark');
|
|
||||||
await _goToTab(tester, AppTab.photos);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('13_Photos_Dark');
|
|
||||||
|
|
||||||
// ---- 14/15 Dark theme, AMOLED (pure black) -----------------------------
|
|
||||||
settings.setAmoledDark(true);
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
await _goToTab(tester, AppTab.files);
|
|
||||||
await tester.pump(const Duration(seconds: 2));
|
|
||||||
await shoot('14_Files_Dark_Amoled');
|
|
||||||
await _goToTab(tester, AppTab.photos);
|
|
||||||
await tester.pump(const Duration(seconds: 3));
|
|
||||||
await shoot('15_Photos_Dark_Amoled');
|
|
||||||
},
|
|
||||||
timeout: const Timeout(Duration(minutes: 15)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts from empty storage, then writes exactly one saved account (the
|
|
||||||
/// demo user, with the app password from `--dart-define`) and the settings
|
|
||||||
/// that make screenshots reproducible: light theme, a fixed brand colour
|
|
||||||
/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav
|
|
||||||
/// tabs, and no first-run notification prompt.
|
|
||||||
Future<void> _seedLoggedInStorage() async {
|
|
||||||
await const FlutterSecureStorage().deleteAll();
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.clear();
|
|
||||||
|
|
||||||
final store = AccountStore();
|
|
||||||
final account = SavedAccount(
|
|
||||||
id: SavedAccount.makeId(_serverUrl, _username),
|
|
||||||
serverUrl: _serverUrl,
|
|
||||||
username: _username,
|
|
||||||
);
|
|
||||||
await store.saveAccounts(prefs, [account]);
|
|
||||||
await store.saveActiveAccountId(prefs, account.id);
|
|
||||||
await store.writePassword(account.id, _appPassword);
|
|
||||||
|
|
||||||
await prefs.setBool('notification_permission_asked', true);
|
|
||||||
await prefs.setString('ui_theme_mode', ThemeMode.light.name);
|
|
||||||
await prefs.setBool('ui_use_dynamic_color', false);
|
|
||||||
await prefs.setStringList('ui_tab_order', [
|
|
||||||
for (final t in AppTab.values) t.name,
|
|
||||||
]);
|
|
||||||
await prefs.setStringList('ui_hidden_tabs', [
|
|
||||||
AppTab.trash.name,
|
|
||||||
AppTab.recent.name,
|
|
||||||
AppTab.offline.name,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
T _read<T>(WidgetTester tester) => Provider.of<T>(
|
|
||||||
tester.element(find.byType(app.MainShellView)),
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> _tap(WidgetTester tester, Finder finder) async {
|
|
||||||
await tester.tap(finder);
|
|
||||||
await tester.pump(const Duration(milliseconds: 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) -
|
|
||||||
/// for chrome that's duplicated across tabs (`IndexedStack` keeps every
|
|
||||||
/// visible tab's widget tree mounted, not just the active one) and/or can be
|
|
||||||
/// scrolled out of its own row's clipped viewport (the controls row,
|
|
||||||
/// `FilesControlsRow`, doesn't fit every control on a narrow screen).
|
|
||||||
/// Scoping to [tab] picks the right one of several identical matches;
|
|
||||||
/// `ensureVisible` scrolls it into view before tapping, which plain
|
|
||||||
/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look
|
|
||||||
/// like "zero hit-testable matches" and throw the same unhelpful
|
|
||||||
/// `Bad state: No element`.
|
|
||||||
Future<void> _tapInTab(WidgetTester tester, Finder tab, Finder matching) async {
|
|
||||||
final target = find.descendant(of: tab, matching: matching).first;
|
|
||||||
await tester.ensureVisible(target);
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await tester.tap(target, warnIfMissed: false);
|
|
||||||
await tester.pump(const Duration(milliseconds: 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _goToTab(WidgetTester tester, AppTab tab) async {
|
|
||||||
// Only one bottom nav bar exists (outside the IndexedStack), so no
|
|
||||||
// cross-tab duplicate to worry about - but it can itself overflow a
|
|
||||||
// narrow screen, so still scroll the target into view rather than relying
|
|
||||||
// on hitTestable() alone (see `_tapInTab`'s doc comment).
|
|
||||||
final target = find
|
|
||||||
.descendant(
|
|
||||||
of: find.byType(BottomNavBar),
|
|
||||||
matching: find.byIcon(tab.icon),
|
|
||||||
)
|
|
||||||
.first;
|
|
||||||
await tester.ensureVisible(target);
|
|
||||||
await tester.pump(const Duration(milliseconds: 300));
|
|
||||||
await tester.tap(target, warnIfMissed: false);
|
|
||||||
await tester.pump(const Duration(milliseconds: 600));
|
|
||||||
|
|
||||||
// Confirm the tap actually landed, rather than trusting it silently - a
|
|
||||||
// scroll-imprecise tap (see above) that misses its target doesn't throw,
|
|
||||||
// it just does nothing, which would otherwise leave every later
|
|
||||||
// screenshot quietly showing whatever tab was already selected instead of
|
|
||||||
// failing loudly. Every destination shows its label all the time now (see
|
|
||||||
// `BottomNavBar`'s doc comment), so label presence can't signal selection
|
|
||||||
// - the icon's color can: it's `accentText` only for the active tab.
|
|
||||||
await _pumpUntilTrue(tester, () {
|
|
||||||
if (target.evaluate().isEmpty) return false;
|
|
||||||
final icon = tester.widget<Icon>(target);
|
|
||||||
final colors = tester.element(target).nooColors;
|
|
||||||
return icon.color == colors.accentText;
|
|
||||||
}, timeout: 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the
|
|
||||||
/// five-tab cap is never hit).
|
|
||||||
void _setVisibleTabs(WidgetTester tester, List<AppTab> tabs) {
|
|
||||||
final settings = _read<SettingsController>(tester);
|
|
||||||
for (final tab in AppTab.values) {
|
|
||||||
if (!tabs.contains(tab)) settings.setTabHidden(tab, true);
|
|
||||||
}
|
|
||||||
for (final tab in tabs) {
|
|
||||||
settings.setTabHidden(tab, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Waits (in real time - this is a live binding, and the app is talking to a
|
|
||||||
/// real server) until [finder] matches something.
|
|
||||||
Future<void> _pumpUntil(
|
|
||||||
WidgetTester tester,
|
|
||||||
Finder finder, {
|
|
||||||
int timeout = 60,
|
|
||||||
}) async {
|
|
||||||
final deadline = DateTime.now().add(Duration(seconds: timeout));
|
|
||||||
while (finder.evaluate().isEmpty) {
|
|
||||||
if (DateTime.now().isAfter(deadline)) {
|
|
||||||
throw TestFailure('Timed out after ${timeout}s waiting for $finder');
|
|
||||||
}
|
|
||||||
await tester.pump(const Duration(milliseconds: 250));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like [_pumpUntil], but for a condition that isn't just "does a finder
|
|
||||||
/// match something" (e.g. a widget property).
|
|
||||||
Future<void> _pumpUntilTrue(
|
|
||||||
WidgetTester tester,
|
|
||||||
bool Function() predicate, {
|
|
||||||
int timeout = 60,
|
|
||||||
}) async {
|
|
||||||
final deadline = DateTime.now().add(Duration(seconds: timeout));
|
|
||||||
while (!predicate()) {
|
|
||||||
if (DateTime.now().isAfter(deadline)) {
|
|
||||||
throw TestFailure('Timed out after ${timeout}s waiting for condition');
|
|
||||||
}
|
|
||||||
await tester.pump(const Duration(milliseconds: 250));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+124
-68
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -44,6 +45,8 @@ import 'widgets/shell/shell_common.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
|
||||||
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
// Split out of the former single `ServerProvider` god object - see
|
// Split out of the former single `ServerProvider` god object - see
|
||||||
@@ -381,15 +384,52 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
String? trashBadge(AppTab tab) =>
|
String? trashBadge(AppTab tab) =>
|
||||||
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null;
|
tab == AppTab.trash && trashCount > 0 ? '$trashCount' : null;
|
||||||
|
|
||||||
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
final navStyle = NooLayout.navStyle(context);
|
||||||
|
// Picking/offline already override the tab set itself (see pinnedTabs
|
||||||
|
// above) - Search doesn't belong in either: there's nothing to search
|
||||||
|
// for a file-picking flow, and Search needs the network Offline mode
|
||||||
|
// doesn't have.
|
||||||
|
final showBottomBarSearch = !overrideActive && settings.searchInBottomBar;
|
||||||
|
|
||||||
// Each tab renders its own sticky selection toolbar inline (right under
|
// Each tab renders its own sticky selection toolbar inline (right under
|
||||||
// its sort/filter row) instead of the shell swapping in a shared one,
|
// its sort/filter row) instead of the shell swapping in a shared one,
|
||||||
// so the nav chrome stays put and usable regardless of selection state.
|
// so the nav chrome stays put and usable regardless of selection state.
|
||||||
|
//
|
||||||
|
// Each tab also builds its own [AppTopBar] (labelled for that tab, not
|
||||||
|
// just whichever is currently selected) and plants it as that tab's own
|
||||||
|
// first sliver - see `buildAppTabView`'s doc comment and `topBarSliver`
|
||||||
|
// in `tabs/tab_state_slivers.dart` - instead of one shared instance
|
||||||
|
// living in `Scaffold.appBar`. That's what gives each tab's top bar
|
||||||
|
// Material's native floating-away-on-scroll-down/reappear-on-scroll-up
|
||||||
|
// behavior, tied to that tab's own `ScrollController`: a fixed
|
||||||
|
// `Scaffold.appBar` can't do that (no per-tab scroll signal reaches
|
||||||
|
// it), and a shared single instance can't show 7 different tab labels
|
||||||
|
// at once now that every tab keeps its own independent scroll state.
|
||||||
|
// Null on desktop (which shows `NooToolbar` instead - embedding it here
|
||||||
|
// too, since `tabStack` is shared by both layouts below, would double
|
||||||
|
// up the top chrome there) and while picking (no top bar at all, same
|
||||||
|
// as this screen's old `Scaffold.appBar: pickRequest == null ? ... :
|
||||||
|
// null`).
|
||||||
final tabStack = Stack(
|
final tabStack = Stack(
|
||||||
children: [
|
children: [
|
||||||
IndexedStack(
|
IndexedStack(
|
||||||
index: selectedIndex,
|
index: selectedIndex,
|
||||||
children: displayTabs
|
children: displayTabs
|
||||||
.map((tab) => buildAppTabView(tab, _scrollControllers[tab]!))
|
.map(
|
||||||
|
(tab) => buildAppTabView(
|
||||||
|
tab,
|
||||||
|
_scrollControllers[tab]!,
|
||||||
|
topBar: isDesktop || pickRequest != null
|
||||||
|
? null
|
||||||
|
: AppTopBar(
|
||||||
|
style: navStyle,
|
||||||
|
tab: tab,
|
||||||
|
searchInBottomBar: showBottomBarSearch,
|
||||||
|
navMenuStyle: settings.navMenuStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
),
|
),
|
||||||
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
if (pick.isDownloadingForPick) const _PickingProgressOverlay(),
|
||||||
@@ -401,84 +441,93 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
|
(selectedTab == AppTab.files || selectedTab == AppTab.photos);
|
||||||
|
|
||||||
Widget scaffold;
|
Widget scaffold;
|
||||||
if (NooLayout.isDesktop(context)) {
|
if (isDesktop) {
|
||||||
scaffold = Scaffold(
|
scaffold = Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
body: Row(
|
// Tablets keep the system bars, so inset for them (desktop's
|
||||||
children: [
|
// insets are zero).
|
||||||
NooSidebar(
|
body: SafeArea(
|
||||||
account: NooSidebarAccount(
|
child: Row(
|
||||||
avatar: NooAvatar(
|
children: [
|
||||||
initials: accountInitial(session.username),
|
NooSidebar(
|
||||||
current: true,
|
account: NooSidebarAccount(
|
||||||
size: 32,
|
avatar: NooAvatar(
|
||||||
|
initials: accountInitial(session.username),
|
||||||
|
current: true,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
name: session.username,
|
||||||
|
subtitle: serverHost(session.serverUrl),
|
||||||
|
onTap: () => showAccountSwitcher(context),
|
||||||
),
|
),
|
||||||
name: session.username,
|
items: [
|
||||||
subtitle: serverHost(session.serverUrl),
|
for (final tab in pinnedTabs)
|
||||||
onTap: () => showAccountSwitcher(context),
|
NooSidebarItem(
|
||||||
),
|
icon: tab.icon,
|
||||||
items: [
|
label: tab.label,
|
||||||
for (final tab in pinnedTabs)
|
count: trashBadge(tab),
|
||||||
NooSidebarItem(
|
selected: tab == selectedTab,
|
||||||
icon: tab.icon,
|
onTap: () => selectTab(tab),
|
||||||
label: tab.label,
|
),
|
||||||
count: trashBadge(tab),
|
for (final tab in hiddenTabs)
|
||||||
selected: tab == selectedTab,
|
NooSidebarItem(
|
||||||
onTap: () => selectTab(tab),
|
icon: tab.icon,
|
||||||
),
|
label: tab.label,
|
||||||
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
|
count: trashBadge(tab),
|
||||||
for (final tab in hiddenTabs)
|
selected: tab == selectedTab,
|
||||||
NooSidebarItem(
|
onTap: () => selectTab(tab),
|
||||||
icon: tab.icon,
|
),
|
||||||
label: tab.label,
|
|
||||||
count: trashBadge(tab),
|
|
||||||
selected: tab == selectedTab,
|
|
||||||
onTap: () => selectTab(tab),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
storage: NooSidebarStorage(
|
|
||||||
value: quotaFraction(quota),
|
|
||||||
detail: quotaDetail(quota),
|
|
||||||
),
|
|
||||||
settings: NooSidebarItem(
|
|
||||||
icon: LucideIcons.settings,
|
|
||||||
label: 'Settings',
|
|
||||||
onTap: () => openSettings(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
NooToolbar(
|
|
||||||
title: selectedTab.label,
|
|
||||||
search: const ShellSearchLauncher(onSurface: true),
|
|
||||||
actions: [
|
|
||||||
if (canUpload)
|
|
||||||
NooButton(
|
|
||||||
icon: LucideIcons.upload,
|
|
||||||
onTap: () => showCreateMenu(context),
|
|
||||||
child: const Text('Upload'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ColoredBox(color: colors.surface, child: tabStack),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
storage: NooSidebarStorage(
|
||||||
|
value: quotaFraction(quota),
|
||||||
|
detail: quotaDetail(quota),
|
||||||
|
),
|
||||||
|
settings: NooSidebarItem(
|
||||||
|
icon: LucideIcons.settings,
|
||||||
|
label: 'Settings',
|
||||||
|
onTap: () => openSettings(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
],
|
child: Column(
|
||||||
|
children: [
|
||||||
|
NooToolbar(
|
||||||
|
title: selectedTab.label,
|
||||||
|
search: const ShellSearchLauncher(onSurface: true),
|
||||||
|
actions: [
|
||||||
|
if (canUpload)
|
||||||
|
NooButton(
|
||||||
|
icon: LucideIcons.upload,
|
||||||
|
onTap: () => showCreateMenu(context),
|
||||||
|
child: const Text('Upload'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ColoredBox(color: colors.surface, child: tabStack),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final navStyle = NooLayout.navStyle(context);
|
final bottomBarStyle = settings.bottomBarStyle;
|
||||||
scaffold = Scaffold(
|
scaffold = Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
drawerScrimColor: colors.scrim,
|
drawerScrimColor: colors.scrim,
|
||||||
appBar: pickRequest == null
|
// The top bar itself no longer lives here - see `tabStack` above.
|
||||||
? AppTopBar(style: navStyle, tab: selectedTab)
|
|
||||||
: null,
|
|
||||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||||
|
// The avatar menu replaces the drawer, so the edge swipe must not
|
||||||
|
// open it either.
|
||||||
|
drawerEnableOpenDragGesture:
|
||||||
|
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||||
|
// Floating needs the body to draw behind the bar's own transparent
|
||||||
|
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||||
|
// short of it like attached does.
|
||||||
|
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
||||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
||||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||||
@@ -487,15 +536,22 @@ class _MainShellViewState extends State<MainShellView> {
|
|||||||
floatingActionButton:
|
floatingActionButton:
|
||||||
pickRequest == null && navStyle == NooNavStyle.android
|
pickRequest == null && navStyle == NooNavStyle.android
|
||||||
? NooFab(
|
? NooFab(
|
||||||
collapsed: !canUpload,
|
collapsed: switch (settings.fabStyle) {
|
||||||
|
FabStyle.auto => !canUpload,
|
||||||
|
FabStyle.mini => true,
|
||||||
|
FabStyle.expanded => false,
|
||||||
|
},
|
||||||
|
barStyle: bottomBarStyle,
|
||||||
onTap: () => showCreateMenu(context),
|
onTap: () => showCreateMenu(context),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: tabStack,
|
body: tabStack,
|
||||||
bottomNavigationBar: BottomNavBar(
|
bottomNavigationBar: BottomNavBar(
|
||||||
style: navStyle,
|
style: navStyle,
|
||||||
|
barStyle: bottomBarStyle,
|
||||||
tabs: pinnedTabs,
|
tabs: pinnedTabs,
|
||||||
selectedIndex: pinnedIndex,
|
selectedIndex: pinnedIndex,
|
||||||
|
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||||
onDestinationSelected: (index) {
|
onDestinationSelected: (index) {
|
||||||
final tappedTab = pinnedTabs[index];
|
final tappedTab = pinnedTabs[index];
|
||||||
if (tappedTab == _currentTab) {
|
if (tappedTab == _currentTab) {
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ enum AppTab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// At most this many tabs may be visible in the bottom nav bar at once —
|
/// At most this many tabs may be visible in the bottom nav bar at once —
|
||||||
/// the rest are reachable through the "more" dropdown instead.
|
/// the rest are reachable through the "more" dropdown instead. The
|
||||||
const int maxVisibleTabs = 5;
|
/// effective cap a screen should actually use is
|
||||||
|
/// [SettingsController.maxVisibleTabs], not this directly - it's one
|
||||||
|
/// lower than this while `searchInBottomBar` is on, since Search then
|
||||||
|
/// takes the freed-up slot itself rather than competing with the user's
|
||||||
|
/// own reorderable tabs for one of these.
|
||||||
|
const int defaultMaxVisibleTabs = 5;
|
||||||
|
|
||||||
extension AppTabInfo on AppTab {
|
extension AppTabInfo on AppTab {
|
||||||
String get label {
|
String get label {
|
||||||
|
|||||||
@@ -39,4 +39,20 @@ class NextcloudShare {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bool get isFolder => itemType == NextcloudItemType.folder;
|
bool get isFolder => itemType == NextcloudItemType.folder;
|
||||||
|
|
||||||
|
NextcloudShare withPermissions(int permissions) => NextcloudShare(
|
||||||
|
id: id,
|
||||||
|
path: path,
|
||||||
|
name: name,
|
||||||
|
itemType: itemType,
|
||||||
|
shareType: shareType,
|
||||||
|
ownerDisplayName: ownerDisplayName,
|
||||||
|
sharedWithDisplayName: sharedWithDisplayName,
|
||||||
|
sharedAt: sharedAt,
|
||||||
|
sharedWithMe: sharedWithMe,
|
||||||
|
url: url,
|
||||||
|
permissions: permissions,
|
||||||
|
token: token,
|
||||||
|
expireDate: expireDate,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ class FilesController extends ChangeNotifier
|
|||||||
NextcloudUserQuota? _quota;
|
NextcloudUserQuota? _quota;
|
||||||
List<NextcloudActivity> _activities = [];
|
List<NextcloudActivity> _activities = [];
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _activating = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
FilesController(this.session) {
|
FilesController(this.session) {
|
||||||
@@ -147,7 +148,10 @@ class FilesController extends ChangeNotifier
|
|||||||
CachePolicy get cachePolicy => _cachePolicy;
|
CachePolicy get cachePolicy => _cachePolicy;
|
||||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||||
@override
|
@override
|
||||||
bool get isLoading => _isLoading;
|
// Also true while a freshly activated account is still restoring prefs
|
||||||
|
// and while the session verifies it, so a switch shows a spinner instead
|
||||||
|
// of briefly flashing the empty-folder message.
|
||||||
|
bool get isLoading => _isLoading || _activating || session.isLoading;
|
||||||
@override
|
@override
|
||||||
String? get errorMessage => _errorMessage;
|
String? get errorMessage => _errorMessage;
|
||||||
NextcloudUserQuota? get quota => _quota;
|
NextcloudUserQuota? get quota => _quota;
|
||||||
@@ -360,9 +364,16 @@ class FilesController extends ChangeNotifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onAccountActivated() async {
|
Future<void> _onAccountActivated() async {
|
||||||
await _restoreDisplayPrefs();
|
_activating = true;
|
||||||
_startCacheRefreshTimerIfNeeded();
|
notifyListeners();
|
||||||
await refreshData();
|
try {
|
||||||
|
await _restoreDisplayPrefs();
|
||||||
|
_startCacheRefreshTimerIfNeeded();
|
||||||
|
await refreshData();
|
||||||
|
} finally {
|
||||||
|
_activating = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -168,9 +168,9 @@ class ItemOperations {
|
|||||||
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
||||||
);
|
);
|
||||||
final existingNames = needsKeepBoth
|
final existingNames = needsKeepBoth
|
||||||
? (await files.fetchFolderListing(destFolderPath))
|
? (await files.fetchFolderListing(
|
||||||
.map((i) => i.name)
|
destFolderPath,
|
||||||
.toSet()
|
)).map((i) => i.name).toSet()
|
||||||
: <String>{};
|
: <String>{};
|
||||||
|
|
||||||
var succeeded = 0;
|
var succeeded = 0;
|
||||||
@@ -303,6 +303,20 @@ class ItemOperations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> updateSharePermissions(
|
||||||
|
NextcloudShare share,
|
||||||
|
int permissions,
|
||||||
|
) async {
|
||||||
|
final service = session.service;
|
||||||
|
if (service == null) return false;
|
||||||
|
try {
|
||||||
|
return await service.updateSharePermissions(share.id, permissions);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[ItemOperations] updateSharePermissions failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes a share (from the file-details/Share sheet, not the Shares
|
/// Deletes a share (from the file-details/Share sheet, not the Shares
|
||||||
/// tab itself - see `SharesController.deleteShare` for that one, which
|
/// tab itself - see `SharesController.deleteShare` for that one, which
|
||||||
/// additionally patches the tab's own list).
|
/// additionally patches the tab's own list).
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import '../models/selection_action.dart';
|
import '../models/selection_action.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/noo/nav/noo_nav_style.dart';
|
||||||
|
|
||||||
/// What swiping a Files list-view item left/right does, user-configurable
|
/// What swiping a Files list-view item left/right does, user-configurable
|
||||||
/// in Settings.
|
/// in Settings.
|
||||||
enum SwipeAction { none, favorite, delete, share }
|
enum SwipeAction { none, favorite, delete, share }
|
||||||
|
|
||||||
|
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
|
||||||
|
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
|
||||||
|
/// always shows the label.
|
||||||
|
enum FabStyle { auto, mini, expanded }
|
||||||
|
|
||||||
/// Thumb/track presets for the video player's seek bar, matching the four
|
/// Thumb/track presets for the video player's seek bar, matching the four
|
||||||
/// combinations offered by other Material You media players: a Material 3
|
/// combinations offered by other Material You media players: a Material 3
|
||||||
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
||||||
@@ -30,6 +36,10 @@ class SettingsController extends ChangeNotifier {
|
|||||||
static const _prefDefaultTab = 'ui_default_tab';
|
static const _prefDefaultTab = 'ui_default_tab';
|
||||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||||
|
static const _prefFabStyle = 'ui_fab_style';
|
||||||
|
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||||
|
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||||
|
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||||
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
|
||||||
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
|
||||||
@@ -39,6 +49,9 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
Color _seedColor = AppTheme.defaultAccent;
|
Color _seedColor = AppTheme.defaultAccent;
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
|
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||||
|
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||||
|
bool _searchInBottomBar = false;
|
||||||
bool _useDynamicColor = true;
|
bool _useDynamicColor = true;
|
||||||
bool _amoledDark = false;
|
bool _amoledDark = false;
|
||||||
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
|
||||||
@@ -54,6 +67,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
||||||
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
||||||
|
FabStyle _fabStyle = FabStyle.auto;
|
||||||
|
|
||||||
// A one-shot request for the shell to switch its active bottom-nav tab
|
// A one-shot request for the shell to switch its active bottom-nav tab
|
||||||
// (e.g. a search result landing on Files) - consumed and cleared by
|
// (e.g. a search result landing on Files) - consumed and cleared by
|
||||||
@@ -80,11 +94,22 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
Color get seedColor => _seedColor;
|
Color get seedColor => _seedColor;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
|
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||||
|
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||||
|
bool get searchInBottomBar => _searchInBottomBar;
|
||||||
bool get useDynamicColor => _useDynamicColor;
|
bool get useDynamicColor => _useDynamicColor;
|
||||||
bool get amoledDark => _amoledDark;
|
bool get amoledDark => _amoledDark;
|
||||||
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
|
||||||
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
bool get tapTabToScrollTop => _tapTabToScrollTop;
|
||||||
|
|
||||||
|
/// The cap a screen should actually enforce for the *regular*, user-
|
||||||
|
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
|
||||||
|
/// [searchInBottomBar] is on, since Search then takes that freed-up slot
|
||||||
|
/// itself (the row's last tab when attached, its own satellite circle
|
||||||
|
/// when floating - see `NooBottomBar`) rather than counting against it.
|
||||||
|
int get maxVisibleTabs =>
|
||||||
|
defaultMaxVisibleTabs - (_searchInBottomBar ? 1 : 0);
|
||||||
|
|
||||||
/// Every tab in the user's configured order, including hidden ones — used
|
/// Every tab in the user's configured order, including hidden ones — used
|
||||||
/// by the reorder/visibility settings UI.
|
/// by the reorder/visibility settings UI.
|
||||||
List<AppTab> get tabOrder => _tabOrder;
|
List<AppTab> get tabOrder => _tabOrder;
|
||||||
@@ -103,6 +128,7 @@ class SettingsController extends ChangeNotifier {
|
|||||||
|
|
||||||
SwipeAction get swipeLeftAction => _swipeLeftAction;
|
SwipeAction get swipeLeftAction => _swipeLeftAction;
|
||||||
SwipeAction get swipeRightAction => _swipeRightAction;
|
SwipeAction get swipeRightAction => _swipeRightAction;
|
||||||
|
FabStyle get fabStyle => _fabStyle;
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
try {
|
try {
|
||||||
@@ -115,6 +141,22 @@ class SettingsController extends ChangeNotifier {
|
|||||||
orElse: () => ThemeMode.system,
|
orElse: () => ThemeMode.system,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
|
||||||
|
if (bottomBarStyleName != null) {
|
||||||
|
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
|
||||||
|
(s) => s.name == bottomBarStyleName,
|
||||||
|
orElse: () => _bottomBarStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final navMenuStyleName = prefs.getString(_prefNavMenuStyle);
|
||||||
|
if (navMenuStyleName != null) {
|
||||||
|
_navMenuStyle = NooNavMenuStyle.values.firstWhere(
|
||||||
|
(s) => s.name == navMenuStyleName,
|
||||||
|
orElse: () => _navMenuStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_searchInBottomBar =
|
||||||
|
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||||
_useDynamicColor =
|
_useDynamicColor =
|
||||||
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
|
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
|
||||||
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
|
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
|
||||||
@@ -208,6 +250,14 @@ class SettingsController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final fabName = prefs.getString(_prefFabStyle);
|
||||||
|
if (fabName != null) {
|
||||||
|
_fabStyle = FabStyle.values.firstWhere(
|
||||||
|
(f) => f.name == fabName,
|
||||||
|
orElse: () => _fabStyle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[SettingsController] Preference restore failed: $e');
|
debugPrint('[SettingsController] Preference restore failed: $e');
|
||||||
@@ -256,6 +306,32 @@ class SettingsController extends ChangeNotifier {
|
|||||||
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setBottomBarStyle(NooBottomBarStyle style) {
|
||||||
|
if (_bottomBarStyle == style) return;
|
||||||
|
_bottomBarStyle = style;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||||
|
if (_navMenuStyle == style) return;
|
||||||
|
_navMenuStyle = style;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefNavMenuStyle, style.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap
|
||||||
|
/// immediately in case the user already has a full 5 regular tabs pinned
|
||||||
|
/// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh
|
||||||
|
/// install/an app update adding a new tab.
|
||||||
|
void setSearchInBottomBar(bool value) {
|
||||||
|
if (_searchInBottomBar == value) return;
|
||||||
|
_searchInBottomBar = value;
|
||||||
|
if (value) _enforceMaxVisibleTabs();
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setBool(_prefSearchInBottomBar, value));
|
||||||
|
}
|
||||||
|
|
||||||
void setTabOrder(List<AppTab> order) {
|
void setTabOrder(List<AppTab> order) {
|
||||||
_tabOrder = order;
|
_tabOrder = order;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -344,4 +420,11 @@ class SettingsController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
|
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setFabStyle(FabStyle style) {
|
||||||
|
if (_fabStyle == style) return;
|
||||||
|
_fabStyle = style;
|
||||||
|
notifyListeners();
|
||||||
|
_prefsFuture.then((p) => p.setString(_prefFabStyle, style.name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,19 @@ class NextcloudService {
|
|||||||
return response.bodyBytes;
|
return response.bodyBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by
|
||||||
|
/// the in-app text editor. Returns whether the server accepted it.
|
||||||
|
Future<bool> putBytes(String itemPath, List<int> bytes) async {
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse(fileUrl(itemPath)),
|
||||||
|
headers: _headers,
|
||||||
|
body: bytes,
|
||||||
|
);
|
||||||
|
return response.statusCode == 200 ||
|
||||||
|
response.statusCode == 201 ||
|
||||||
|
response.statusCode == 204;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> testConnection() async {
|
Future<bool> testConnection() async {
|
||||||
try {
|
try {
|
||||||
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
|
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
|
||||||
@@ -1525,6 +1538,22 @@ class NextcloudService {
|
|||||||
return response.statusCode == 200;
|
return response.statusCode == 200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Changes a share's permission bitmask (1 read, 2 update, 4 create,
|
||||||
|
/// 8 delete, 16 reshare).
|
||||||
|
Future<bool> updateSharePermissions(String shareId, int permissions) async {
|
||||||
|
final url =
|
||||||
|
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
|
||||||
|
debugPrint('[Nextcloud OCS] Updating share $shareId permissions');
|
||||||
|
|
||||||
|
final response = await http.put(
|
||||||
|
Uri.parse(url),
|
||||||
|
headers: _headers,
|
||||||
|
body: {'permissions': '$permissions'},
|
||||||
|
);
|
||||||
|
debugPrint('[Nextcloud OCS] Update share status: ${response.statusCode}');
|
||||||
|
return response.statusCode == 200;
|
||||||
|
}
|
||||||
|
|
||||||
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
|
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
|
||||||
final path = (raw['path'] ?? '/').toString();
|
final path = (raw['path'] ?? '/').toString();
|
||||||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
|||||||
@@ -356,8 +356,9 @@ class NooMotion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
/// The one shadow the design system allows, from `tokens/spacing.css`'s
|
||||||
/// `--shadow-dialog` - desktop dialogs only. Nothing else in the app should
|
/// `--shadow-dialog` - desktop dialogs, and [NooBottomBarStyle.floating]'s
|
||||||
/// use a `BoxShadow`.
|
/// bar/search satellite (the only other thing in the app that's genuinely
|
||||||
|
/// floating above other content). Nothing else should use a `BoxShadow`.
|
||||||
const nooDialogShadow = BoxShadow(
|
const nooDialogShadow = BoxShadow(
|
||||||
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
color: Color(0x241E002F), // rgba(30,0,47,.14)
|
||||||
blurRadius: 48,
|
blurRadius: 48,
|
||||||
|
|||||||
+186
-71
@@ -1,5 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../theme/design_tokens.dart';
|
import '../theme/design_tokens.dart';
|
||||||
|
import '../widgets/noo/lists/noo_grouped_list.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/noo_layout.dart';
|
import '../widgets/noo/noo_layout.dart';
|
||||||
@@ -15,31 +18,73 @@ import '../widgets/settings/settings_tabs.dart';
|
|||||||
|
|
||||||
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
|
/// Settings, pushed on top of the shell (DESIGN_SYSTEM.md 4's 9-section
|
||||||
/// order: account card, accounts, security, file sync, files cache,
|
/// order: account card, accounts, security, file sync, files cache,
|
||||||
/// appearance, tabs, action bar, swipe on a file). One column of
|
/// appearance, tabs, action bar, swipe on a file).
|
||||||
/// [SettingsSection]s (and [SettingsActionBarSection], which has no option
|
///
|
||||||
/// rows of its own to put in one - just the reorder list) on mobile, each
|
/// Every layout (including wide tablet, in a centered 640px column) is
|
||||||
/// individually collapsible (expanded by default, tap its label to
|
/// a two-level menu, the way native iOS/Android Settings apps
|
||||||
/// collapse - see `SettingsSection`/`NooGroupedList`'s `collapsible` param)
|
/// work: [SettingsAccountCard] (the account summary, not a settings picker)
|
||||||
/// so a long Settings screen can be collapsed down rather than needing a
|
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and
|
||||||
/// separate jump rail (an earlier version had one; it added a second,
|
/// every other section becomes one tappable [NooSettingsRow] - icon, title,
|
||||||
/// redundant way to navigate on top of plain scrolling and still didn't
|
/// chevron - in a [NooGroupedList] below it. Tapping a row pushes a new
|
||||||
/// shorten the page). Desktop uses a 2-column grid of cards instead, wide
|
/// [_SettingsCategoryScreen] with its own [NooTopBar]/[NooTopBarBack],
|
||||||
/// enough to see most sections without scrolling, so it gets neither. See
|
/// containing just that section's content full-screen. This replaces an
|
||||||
/// each `widgets/settings/*.dart` file for a section's own content and any
|
/// earlier design where every section rendered inline in one long
|
||||||
/// setting that had to be slotted in or grouped under "Advanced appearance".
|
/// 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 {
|
class AccountView extends StatelessWidget {
|
||||||
const AccountView({super.key});
|
const AccountView({super.key});
|
||||||
|
|
||||||
static const _sections = <Widget>[
|
static final _categories = <_SettingsCategory>[
|
||||||
SettingsAccountCard(),
|
_SettingsCategory(
|
||||||
SettingsAccountsSection(),
|
title: 'Accounts',
|
||||||
SettingsSecuritySection(),
|
icon: LucideIcons.users,
|
||||||
SettingsFileSyncSection(),
|
builder: (_) => const SettingsAccountsSection(),
|
||||||
SettingsFilesCacheSection(),
|
),
|
||||||
SettingsAppearanceSection(),
|
_SettingsCategory(
|
||||||
SettingsTabsSection(),
|
title: 'Security',
|
||||||
SettingsActionBarSection(),
|
icon: LucideIcons.lock,
|
||||||
SettingsSwipeSection(),
|
builder: (_) => const SettingsSecuritySection(),
|
||||||
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'File sync',
|
||||||
|
icon: LucideIcons.cloud,
|
||||||
|
builder: (_) => const SettingsFileSyncSection(),
|
||||||
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'Files cache',
|
||||||
|
icon: LucideIcons.database,
|
||||||
|
builder: (_) => const SettingsFilesCacheSection(),
|
||||||
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'Appearance',
|
||||||
|
icon: LucideIcons.sunMoon,
|
||||||
|
builder: (_) => const SettingsAppearanceSection(),
|
||||||
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'Tabs',
|
||||||
|
icon: LucideIcons.layoutGrid,
|
||||||
|
builder: (_) => const SettingsTabsSection(),
|
||||||
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'Action bar',
|
||||||
|
icon: LucideIcons.slidersHorizontal,
|
||||||
|
builder: (_) => const SettingsActionBarSection(),
|
||||||
|
),
|
||||||
|
_SettingsCategory(
|
||||||
|
title: 'Swipe on a file',
|
||||||
|
icon: LucideIcons.chevronsLeftRight,
|
||||||
|
builder: (_) => const SettingsSwipeSection(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -50,7 +95,7 @@ class AccountView extends StatelessWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colors.bg,
|
backgroundColor: colors.bg,
|
||||||
appBar: desktop
|
appBar: desktop
|
||||||
? const NooToolbar(title: 'Settings')
|
? _desktopBar(context, 'Settings')
|
||||||
: NooTopBar(
|
: NooTopBar(
|
||||||
style: NooLayout.navStyle(context),
|
style: NooLayout.navStyle(context),
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -59,19 +104,55 @@ class AccountView extends StatelessWidget {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: desktop
|
child: desktop
|
||||||
? _DesktopGrid(sections: _sections)
|
? Center(
|
||||||
: _MobileList(sections: _sections),
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 640),
|
||||||
|
child: _MobileMenu(categories: _categories),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _MobileMenu(categories: _categories),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MobileList extends StatelessWidget {
|
/// One row of [AccountView]'s mobile top-level menu: a title, a leading
|
||||||
final List<Widget> sections;
|
/// icon (reused from that section's own first/most-representative row, so
|
||||||
const _MobileList({required this.sections});
|
/// the menu icon and the content the user lands on agree), and a builder
|
||||||
|
/// for the section content shown on [_SettingsCategoryScreen].
|
||||||
|
class _SettingsCategory {
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final WidgetBuilder builder;
|
||||||
|
|
||||||
|
_SettingsCategory({
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mobile top-level Settings list: [SettingsAccountCard] pinned above a
|
||||||
|
/// single [NooGroupedList] of category rows, one per [AccountView._categories]
|
||||||
|
/// entry - the menu half of the menu-then-pushed-screen pattern described on
|
||||||
|
/// [AccountView]'s own doc comment.
|
||||||
|
class _MobileMenu extends StatelessWidget {
|
||||||
|
final List<_SettingsCategory> categories;
|
||||||
|
const _MobileMenu({required this.categories});
|
||||||
|
|
||||||
|
void _open(BuildContext context, _SettingsCategory category) {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => _SettingsCategoryScreen(category: category),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(
|
padding: const EdgeInsets.fromLTRB(
|
||||||
NooSpace.sm,
|
NooSpace.sm,
|
||||||
@@ -81,62 +162,96 @@ class _MobileList extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
for (final section in sections) ...[
|
const SettingsAccountCard(),
|
||||||
section,
|
const SizedBox(height: NooSpace.xl),
|
||||||
const SizedBox(height: NooSpace.xl),
|
NooGroupedList(
|
||||||
],
|
children: [
|
||||||
|
for (final category in categories)
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: category.icon,
|
||||||
|
label: Text(category.title),
|
||||||
|
trailing: Icon(
|
||||||
|
LucideIcons.chevronRight,
|
||||||
|
size: 18,
|
||||||
|
color: colors.fg3,
|
||||||
|
),
|
||||||
|
onTap: () => _open(context, category),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Splits the sections between two columns rather than a strict grid, since
|
/// A pushed, single-category Settings screen: [NooTopBar] titled with the
|
||||||
/// each card's content height varies a lot (the tab reorder list and the
|
/// category, a back button, and just that section's own content - the
|
||||||
/// accounts list can both run much taller than, say, Security) - a fixed
|
/// pushed half of [AccountView]'s mobile menu-then-screen pattern. Always
|
||||||
/// grid would either clip content or leave large gaps.
|
/// built in a mobile-width context (desktop never opens this screen; it
|
||||||
class _DesktopGrid extends StatelessWidget {
|
/// shows every section inline in its own grid instead), so the section
|
||||||
final List<Widget> sections;
|
/// widgets inside render their normal mobile layout unchanged.
|
||||||
const _DesktopGrid({required this.sections});
|
class _SettingsCategoryScreen extends StatelessWidget {
|
||||||
|
final _SettingsCategory category;
|
||||||
|
const _SettingsCategoryScreen({required this.category});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final left = <Widget>[];
|
final colors = context.nooColors;
|
||||||
final right = <Widget>[];
|
final desktop = NooLayout.isDesktop(context);
|
||||||
for (var i = 0; i < sections.length; i++) {
|
|
||||||
(i.isEven ? left : right).add(sections[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Scaffold(
|
||||||
padding: const EdgeInsets.all(NooSpace.xl),
|
backgroundColor: colors.bg,
|
||||||
child: ConstrainedBox(
|
appBar: desktop
|
||||||
constraints: const BoxConstraints(maxWidth: 1040),
|
? _desktopBar(context, category.title)
|
||||||
child: Row(
|
: NooTopBar(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: NooLayout.navStyle(context),
|
||||||
children: [
|
title: category.title,
|
||||||
Expanded(child: _Column(children: left)),
|
leading: const NooTopBarBack(),
|
||||||
const SizedBox(width: NooSpace.xl),
|
),
|
||||||
Expanded(child: _Column(children: right)),
|
body: SafeArea(
|
||||||
],
|
top: false,
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: desktop ? 640 : double.infinity,
|
||||||
|
),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.sm,
|
||||||
|
NooSpace.xxl,
|
||||||
|
),
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
children: [category.builder(context)],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Column extends StatelessWidget {
|
PreferredSizeWidget _desktopBar(BuildContext context, String title) {
|
||||||
final List<Widget> children;
|
final top = MediaQuery.paddingOf(context).top;
|
||||||
const _Column({required this.children});
|
return PreferredSize(
|
||||||
|
preferredSize: Size.fromHeight(NooToolbar.outerHeight + top),
|
||||||
@override
|
child: Padding(
|
||||||
Widget build(BuildContext context) {
|
padding: EdgeInsets.only(top: top),
|
||||||
return Column(
|
child: NooToolbar(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
titleWidget: Row(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
for (final child in children) ...[
|
spacing: NooSpace.sm,
|
||||||
child,
|
children: [
|
||||||
const SizedBox(height: NooSpace.xl),
|
IconButton(
|
||||||
],
|
icon: const Icon(LucideIcons.arrowLeft),
|
||||||
],
|
tooltip: 'Back',
|
||||||
);
|
onPressed: () => Navigator.maybePop(context),
|
||||||
}
|
),
|
||||||
|
Text(title),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ import '../widgets/tabs/tab_state_slivers.dart';
|
|||||||
class ActivityView extends StatelessWidget {
|
class ActivityView extends StatelessWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const ActivityView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const ActivityView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -90,17 +94,25 @@ class ActivityView extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (topBar != null) topBarSliver(topBar!),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||||
feed,
|
feed,
|
||||||
...tabBottomInsetSlivers,
|
...tabBottomInsetSlivers(context),
|
||||||
];
|
];
|
||||||
|
|
||||||
final scrollView = CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
controller: scrollController,
|
// controls row rides up under the status bar once the floating top bar
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// above it fully collapses.
|
||||||
// refresh can't be triggered on an empty or single-item feed.
|
final scrollView = SafeArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
top: true,
|
||||||
slivers: contentSlivers,
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item feed.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
|||||||
import '../widgets/noo/core/noo_button.dart';
|
import '../widgets/noo/core/noo_button.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
@@ -50,7 +51,11 @@ import 'move_copy_destination_picker.dart';
|
|||||||
class FavoritesView extends StatefulWidget {
|
class FavoritesView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const FavoritesView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const FavoritesView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FavoritesView> createState() => _FavoritesViewState();
|
State<FavoritesView> createState() => _FavoritesViewState();
|
||||||
@@ -507,6 +512,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final contentSlivers = <Widget>[
|
final contentSlivers = <Widget>[
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
pinned: !_isSelecting,
|
pinned: !_isSelecting,
|
||||||
delegate: StickyHeaderDelegate(
|
delegate: StickyHeaderDelegate(
|
||||||
@@ -580,7 +586,9 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
),
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: isDesktop ? 5 : 2,
|
crossAxisCount: isDesktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||||
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
childAspectRatio: isDesktop ? 0.92 : 0.85,
|
||||||
@@ -638,7 +646,7 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
}, childCount: favorites.length),
|
}, childCount: favorites.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -652,12 +660,19 @@ class _FavoritesViewState extends State<FavoritesView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: favoritesController.fetchAll,
|
onRefresh: favoritesController.fetchAll,
|
||||||
child: CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
controller: widget.scrollController,
|
// controls row rides up under the status bar once the floating
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// top bar above it fully collapses.
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
child: SafeArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
top: true,
|
||||||
slivers: contentSlivers,
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../widgets/noo/overlays/noo_overlay_header.dart';
|
|||||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/viewer/media_action_bar.dart';
|
import '../widgets/viewer/media_action_bar.dart';
|
||||||
|
import '../widgets/viewer/media_details_panel.dart';
|
||||||
import '../widgets/viewer/media_image_preview.dart';
|
import '../widgets/viewer/media_image_preview.dart';
|
||||||
import '../widgets/viewer/media_pdf_preview.dart';
|
import '../widgets/viewer/media_pdf_preview.dart';
|
||||||
import '../widgets/viewer/media_text_preview.dart';
|
import '../widgets/viewer/media_text_preview.dart';
|
||||||
@@ -154,6 +155,43 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
/// also drag the gallery to the next item.
|
/// also drag the gallery to the next item.
|
||||||
bool _isZoomed = false;
|
bool _isZoomed = false;
|
||||||
|
|
||||||
|
final Map<int, Offset> _pointerStart = {};
|
||||||
|
bool _multiTouch = false;
|
||||||
|
|
||||||
|
void _onPointerDown(PointerDownEvent e) {
|
||||||
|
if (_pointerStart.isNotEmpty) _multiTouch = true;
|
||||||
|
_pointerStart[e.pointer] = e.position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw pointer events rather than a drag recognizer, so this never competes
|
||||||
|
// with the PageView / InteractiveViewer gestures in the arena.
|
||||||
|
void _onPointerUp(PointerUpEvent e) {
|
||||||
|
final start = _pointerStart.remove(e.pointer);
|
||||||
|
final wasMulti = _multiTouch;
|
||||||
|
if (_pointerStart.isEmpty) _multiTouch = false;
|
||||||
|
if (start == null || wasMulti || _isZoomed || !_isSwipeable) return;
|
||||||
|
final delta = e.position - start;
|
||||||
|
if (delta.dy < -80 && delta.dy.abs() > delta.dx.abs() * 1.5) {
|
||||||
|
_openDetails();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final _panelKey = GlobalKey<MediaDetailsPanelState>();
|
||||||
|
|
||||||
|
void _openDetails() {
|
||||||
|
if (NooLayout.isDesktop(context)) {
|
||||||
|
DetailsSheet.show(context, _currentItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _controlsVisible = true);
|
||||||
|
_panelKey.currentState?.expand();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPointerCancel(PointerCancelEvent e) {
|
||||||
|
_pointerStart.remove(e.pointer);
|
||||||
|
if (_pointerStart.isEmpty) _multiTouch = false;
|
||||||
|
}
|
||||||
|
|
||||||
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
|
NextcloudItem get _currentItem => _mediaItems[_currentIndex];
|
||||||
|
|
||||||
bool get _isOffline => widget.localPathResolver != null;
|
bool get _isOffline => widget.localPathResolver != null;
|
||||||
@@ -394,13 +432,25 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
backgroundColor: stageColor,
|
backgroundColor: stageColor,
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: () => setState(() => _controlsVisible = !_controlsVisible),
|
onTap: () {
|
||||||
|
final panel = _panelKey.currentState;
|
||||||
|
if (panel != null && panel.isExpanded) {
|
||||||
|
panel.collapse();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _controlsVisible = !_controlsVisible);
|
||||||
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Listener(
|
||||||
color: stageColor,
|
onPointerDown: _onPointerDown,
|
||||||
child: _buildBody(context, session),
|
onPointerUp: _onPointerUp,
|
||||||
|
onPointerCancel: _onPointerCancel,
|
||||||
|
child: Container(
|
||||||
|
color: stageColor,
|
||||||
|
child: _buildBody(context, session),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (_downloadProgress != null)
|
if (_downloadProgress != null)
|
||||||
@@ -522,25 +572,35 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_controlsVisible,
|
ignoring: !_controlsVisible,
|
||||||
child: MediaActionBar(
|
child: MediaDetailsPanel(
|
||||||
isFavorite: _currentItem.isFavorite,
|
key: _panelKey,
|
||||||
isBusy: _isBusy,
|
item: _currentItem,
|
||||||
// Favorite/delete/download-to-device all need a live
|
builder: (context, handle, below) => MediaActionBar(
|
||||||
// server - hidden rather than shown-and-failing while
|
onDetails: NooLayout.isDesktop(context)
|
||||||
// browsing an already-local file from the Offline tab.
|
? _openDetails
|
||||||
showServerActions: !_isOffline,
|
: null,
|
||||||
transportControls:
|
handle: NooLayout.isDesktop(context) ? null : handle,
|
||||||
_isSwipeable &&
|
below: NooLayout.isDesktop(context) ? null : below,
|
||||||
_currentItem.type == NextcloudItemType.video &&
|
isFavorite: _currentItem.isFavorite,
|
||||||
_videoController != null
|
isBusy: _isBusy,
|
||||||
? VideoTransportControls(controller: _videoController!)
|
// Favorite/delete/download-to-device all need a live
|
||||||
: null,
|
// server - hidden rather than shown-and-failing while
|
||||||
onShare: () => ShareSheet.show(context, _currentItem),
|
// browsing an already-local file from the Offline tab.
|
||||||
onFavorite: () => _toggleFavorite(ops),
|
showServerActions: !_isOffline,
|
||||||
onDelete: () => _deleteCurrentItem(ops),
|
transportControls:
|
||||||
onOpenExternally: () => _openExternally(session),
|
_isSwipeable &&
|
||||||
onDownload: () => _downloadToDevice(session),
|
_currentItem.type == NextcloudItemType.video &&
|
||||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
_videoController != null
|
||||||
|
? VideoTransportControls(
|
||||||
|
controller: _videoController!,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onShare: () => ShareSheet.show(context, _currentItem),
|
||||||
|
onFavorite: () => _toggleFavorite(ops),
|
||||||
|
onDelete: () => _deleteCurrentItem(ops),
|
||||||
|
onOpenExternally: () => _openExternally(session),
|
||||||
|
onDownload: () => _downloadToDevice(session),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -659,6 +719,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
|||||||
default:
|
default:
|
||||||
if (_isPdf) {
|
if (_isPdf) {
|
||||||
return MediaPdfPreview(
|
return MediaPdfPreview(
|
||||||
|
topInset: _controlsVisible
|
||||||
|
? MediaQuery.paddingOf(context).top + 60
|
||||||
|
: 0,
|
||||||
item: widget.item,
|
item: widget.item,
|
||||||
session: session,
|
session: session,
|
||||||
localPathResolver: widget.localPathResolver,
|
localPathResolver: widget.localPathResolver,
|
||||||
|
|||||||
@@ -39,10 +39,12 @@ import '../widgets/noo/lists/noo_selection_bar.dart';
|
|||||||
import '../widgets/noo/lists/noo_settings_row.dart';
|
import '../widgets/noo/lists/noo_settings_row.dart';
|
||||||
import '../widgets/noo/lists/noo_summary_card.dart';
|
import '../widgets/noo/lists/noo_summary_card.dart';
|
||||||
import '../widgets/noo/media/noo_grid_card.dart';
|
import '../widgets/noo/media/noo_grid_card.dart';
|
||||||
|
import '../widgets/noo/nav/noo_bottom_bar.dart';
|
||||||
import '../widgets/noo/noo_layout.dart';
|
import '../widgets/noo/noo_layout.dart';
|
||||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
import '../widgets/synced_header_scaffold.dart' show formatBytes;
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
@@ -59,10 +61,15 @@ class FilesView extends StatefulWidget {
|
|||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
final bool offline;
|
final bool offline;
|
||||||
|
|
||||||
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
const FilesView({
|
const FilesView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
this.offline = false,
|
this.offline = false,
|
||||||
|
this.topBar,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -653,6 +660,7 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Pinned in both states - while browsing this is the controls row
|
// Pinned in both states - while browsing this is the controls row
|
||||||
// (+ breadcrumbs), while selecting it's the selection bar (see
|
// (+ breadcrumbs), while selecting it's the selection bar (see
|
||||||
// `topRow` above): either way it's the one thing that always stays
|
// `topRow` above): either way it's the one thing that always stays
|
||||||
@@ -746,7 +754,9 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
),
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: isDesktop ? 5 : 2,
|
crossAxisCount: isDesktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||||
@@ -815,6 +825,15 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
}, childCount: browser.items.length),
|
}, childCount: browser.items.length),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// List/grid/table above only pad NooSpace.lg at the bottom - plenty
|
||||||
|
// once Scaffold shrinks the body above an attached bar, but floating
|
||||||
|
// draws the body behind the bar instead, so it needs the bar's own
|
||||||
|
// footprint added on top or the last row ends up under it.
|
||||||
|
if (context.watch<SettingsController>().bottomBarStyle ==
|
||||||
|
NooBottomBarStyle.floating)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: bottomBarClearance(context)),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -836,14 +855,30 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
unawaited(sync.syncOnPull());
|
unawaited(sync.syncOnPull());
|
||||||
return browser.reload();
|
return browser.reload();
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
// `topBarSliver`'s floating header can collapse all the way to
|
||||||
controller: widget.scrollController,
|
// zero height (fully scrolled away), at which point the sticky
|
||||||
// Pull-to-refresh needs a scroll physics that allows dragging
|
// controls row right below it in `contentSlivers` would otherwise
|
||||||
// past the edge even when content doesn't fill the viewport -
|
// ride up underneath the status bar instead of stopping below it
|
||||||
// an empty or single-item list otherwise can't be pulled at all
|
// - the floating top bar used to be the only thing reserving that
|
||||||
// under the platform default physics.
|
// space (via its own internal `SafeArea`), and that reservation
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
// disappears along with it once it's fully hidden. Wrapping the
|
||||||
slivers: contentSlivers,
|
// whole scroll view keeps the inset outside the scrolling region
|
||||||
|
// entirely, so it's never implicated in the floating header's own
|
||||||
|
// collapse/reveal math - safe to apply unconditionally, since
|
||||||
|
// desktop's `MediaQuery.padding.top` is 0 anyway (no topBar / no
|
||||||
|
// status bar there).
|
||||||
|
child: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// Pull-to-refresh needs a scroll physics that allows dragging
|
||||||
|
// past the edge even when content doesn't fill the viewport -
|
||||||
|
// an empty or single-item list otherwise can't be pulled at
|
||||||
|
// all under the platform default physics.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1290,6 +1325,9 @@ class _FilesViewState extends State<FilesView> {
|
|||||||
selected: isSelected,
|
selected: isSelected,
|
||||||
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)
|
||||||
|
? null
|
||||||
|
: () => _toggleSelection(item),
|
||||||
onMore: !_isSelecting && !picking
|
onMore: !_isSelecting && !picking
|
||||||
? () => _showItemActions(context, item)
|
? () => _showItemActions(context, item)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -284,7 +284,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: desktop ? 5 : 2,
|
crossAxisCount: desktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: desktop ? 16 : 10,
|
crossAxisSpacing: desktop ? 16 : 10,
|
||||||
mainAxisSpacing: desktop ? 16 : 10,
|
mainAxisSpacing: desktop ? 16 : 10,
|
||||||
|
|||||||
+29
-11
@@ -30,6 +30,7 @@ import '../widgets/noo/overlays/noo_sheet.dart';
|
|||||||
import '../widgets/share_sheet.dart';
|
import '../widgets/share_sheet.dart';
|
||||||
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
import '../widgets/sort_menu_button.dart' show sortFieldLabel;
|
||||||
import '../widgets/sticky_header_delegate.dart';
|
import '../widgets/sticky_header_delegate.dart';
|
||||||
|
import '../widgets/tabs/tab_state_slivers.dart';
|
||||||
import 'file_viewer_screen.dart';
|
import 'file_viewer_screen.dart';
|
||||||
import 'move_copy_destination_picker.dart';
|
import 'move_copy_destination_picker.dart';
|
||||||
|
|
||||||
@@ -53,7 +54,11 @@ class _MonthGroup {
|
|||||||
class PhotosView extends StatefulWidget {
|
class PhotosView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const PhotosView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const PhotosView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<PhotosView> createState() => _PhotosViewState();
|
State<PhotosView> createState() => _PhotosViewState();
|
||||||
@@ -239,6 +244,7 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
// Sticky while browsing; once selecting, the selection bar takes over
|
// Sticky while browsing; once selecting, the selection bar takes over
|
||||||
// the same slot instead.
|
// the same slot instead.
|
||||||
SliverPersistentHeader(
|
SliverPersistentHeader(
|
||||||
@@ -336,7 +342,11 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
itemCount: group.items.length,
|
itemCount: group.items.length,
|
||||||
columns: isDesktop
|
columns: isDesktop
|
||||||
? NooPhotoGrid.desktopColumns
|
? NooPhotoGrid.desktopColumns
|
||||||
: NooPhotoGrid.mobileColumns,
|
: 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,
|
||||||
@@ -346,9 +356,10 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Fixed clearance so the last row isn't hidden behind the floating
|
// Clearance so the last row isn't hidden behind the nav bar,
|
||||||
// nav bar, regardless of grid length.
|
// regardless of grid length - see `bottomBarClearance`'s own doc
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
// comment for why this has to be dynamic rather than a flat 100.
|
||||||
|
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||||
];
|
];
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -362,12 +373,19 @@ class _PhotosViewState extends State<PhotosView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: photosController.fetchAllMedia,
|
onRefresh: photosController.fetchAllMedia,
|
||||||
child: CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
controller: widget.scrollController,
|
// controls row rides up under the status bar once the floating
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// top bar above it fully collapses.
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
child: SafeArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
top: true,
|
||||||
slivers: contentSlivers,
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ import 'file_viewer_screen.dart';
|
|||||||
class RecentView extends StatefulWidget {
|
class RecentView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const RecentView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const RecentView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RecentView> createState() => _RecentViewState();
|
State<RecentView> createState() => _RecentViewState();
|
||||||
@@ -54,6 +58,7 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
const SliverToBoxAdapter(child: SizedBox(height: NooSpace.md)),
|
||||||
if (recent.isLoading && items.isEmpty)
|
if (recent.isLoading && items.isEmpty)
|
||||||
tabLoadingSliver
|
tabLoadingSliver
|
||||||
@@ -74,7 +79,7 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
_buildDesktopTable(context, groups)
|
_buildDesktopTable(context, groups)
|
||||||
else
|
else
|
||||||
_buildMobileGroups(context, groups),
|
_buildMobileGroups(context, groups),
|
||||||
...tabBottomInsetSlivers,
|
...tabBottomInsetSlivers(context),
|
||||||
];
|
];
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
@@ -83,12 +88,19 @@ class _RecentViewState extends State<RecentView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: recent.fetchAll,
|
onRefresh: recent.fetchAll,
|
||||||
child: CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
controller: widget.scrollController,
|
// controls row rides up under the status bar once the floating top
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// bar above it fully collapses.
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
child: SafeArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
top: true,
|
||||||
slivers: contentSlivers,
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -209,7 +209,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
|
|||||||
padding: EdgeInsets.symmetric(horizontal: gutter),
|
padding: EdgeInsets.symmetric(horizontal: gutter),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: desktop ? 5 : 2,
|
crossAxisCount: desktop
|
||||||
|
? 5
|
||||||
|
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||||
childAspectRatio: desktop ? 1.05 : 0.92,
|
childAspectRatio: desktop ? 1.05 : 0.92,
|
||||||
crossAxisSpacing: desktop ? 16 : 10,
|
crossAxisSpacing: desktop ? 16 : 10,
|
||||||
mainAxisSpacing: desktop ? 16 : 10,
|
mainAxisSpacing: desktop ? 16 : 10,
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ String _permissionLabel(int permissions) {
|
|||||||
class SharesView extends StatefulWidget {
|
class SharesView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const SharesView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const SharesView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SharesView> createState() => _SharesViewState();
|
State<SharesView> createState() => _SharesViewState();
|
||||||
@@ -81,6 +85,7 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
final isDesktop = NooLayout.isDesktop(context);
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
NooLayout.gutter(context),
|
NooLayout.gutter(context),
|
||||||
@@ -120,7 +125,7 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
_buildDesktopTable(context, shares)
|
_buildDesktopTable(context, shares)
|
||||||
else
|
else
|
||||||
_buildMobileList(context, shares),
|
_buildMobileList(context, shares),
|
||||||
...tabBottomInsetSlivers,
|
...tabBottomInsetSlivers(context),
|
||||||
];
|
];
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
@@ -129,12 +134,19 @@ class _SharesViewState extends State<SharesView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: sharesController.fetchAll,
|
onRefresh: sharesController.fetchAll,
|
||||||
child: CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
controller: widget.scrollController,
|
// controls row rides up under the status bar once the floating top
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// bar above it fully collapses.
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
child: SafeArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
top: true,
|
||||||
slivers: contentSlivers,
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import '../widgets/tabs/tab_state_slivers.dart';
|
|||||||
class TrashView extends StatefulWidget {
|
class TrashView extends StatefulWidget {
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
|
|
||||||
const TrashView({super.key, required this.scrollController});
|
/// This tab's own shell top bar, planted as its first sliver - see
|
||||||
|
/// `buildAppTabView`'s doc comment. Null on desktop and while picking.
|
||||||
|
final PreferredSizeWidget? topBar;
|
||||||
|
|
||||||
|
const TrashView({super.key, required this.scrollController, this.topBar});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<TrashView> createState() => _TrashViewState();
|
State<TrashView> createState() => _TrashViewState();
|
||||||
@@ -50,6 +54,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
final isDesktop = NooLayout.isDesktop(context);
|
final isDesktop = NooLayout.isDesktop(context);
|
||||||
|
|
||||||
final List<Widget> contentSlivers = [
|
final List<Widget> contentSlivers = [
|
||||||
|
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
NooLayout.gutter(context),
|
NooLayout.gutter(context),
|
||||||
@@ -89,7 +94,7 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
_buildDesktopTable(context, trash)
|
_buildDesktopTable(context, trash)
|
||||||
else
|
else
|
||||||
_buildMobileList(context, trash),
|
_buildMobileList(context, trash),
|
||||||
...tabBottomInsetSlivers,
|
...tabBottomInsetSlivers(context),
|
||||||
];
|
];
|
||||||
|
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
@@ -98,12 +103,19 @@ class _TrashViewState extends State<TrashView> {
|
|||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
backgroundColor: colors.surface,
|
backgroundColor: colors.surface,
|
||||||
onRefresh: trashController.fetchAll,
|
onRefresh: trashController.fetchAll,
|
||||||
child: CustomScrollView(
|
// See files_view.dart's identical fix - without this, the sticky
|
||||||
controller: widget.scrollController,
|
// controls row rides up under the status bar once the floating top
|
||||||
// See files_view.dart's identical fix - without this, pull-to-
|
// bar above it fully collapses.
|
||||||
// refresh can't be triggered on an empty or single-item list.
|
child: SafeArea(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
top: true,
|
||||||
slivers: contentSlivers,
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: widget.scrollController,
|
||||||
|
// See files_view.dart's identical fix - without this, pull-to-
|
||||||
|
// refresh can't be triggered on an empty or single-item list.
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
slivers: contentSlivers,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,20 @@ import '../views/trash_view.dart';
|
|||||||
/// Builds the view widget for a given [AppTab]. Shared by the main shell
|
/// Builds the view widget for a given [AppTab]. Shared by the main shell
|
||||||
/// (for visible tabs) and the "more tabs" dropdown (for launching a tab
|
/// (for visible tabs) and the "more tabs" dropdown (for launching a tab
|
||||||
/// that's currently hidden from the bottom nav bar).
|
/// that's currently hidden from the bottom nav bar).
|
||||||
Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
///
|
||||||
|
/// [topBar], when given, is that tab's own [AppTopBar] instance (built by
|
||||||
|
/// the caller, which owns `NooLayout.navStyle`/pick-mode/search-in-bottom-
|
||||||
|
/// bar state) - each view plants it as its own first sliver (see
|
||||||
|
/// `topBarSliver` in `tabs/tab_state_slivers.dart`) so it scrolls away and
|
||||||
|
/// reappears independently, tied to that tab's own `ScrollController`
|
||||||
|
/// rather than living in the shared `Scaffold.appBar`. Null on desktop
|
||||||
|
/// (which shows `NooToolbar` instead) and while picking (no top bar at
|
||||||
|
/// all), matching `Scaffold.appBar`'s old `pickRequest == null` guard.
|
||||||
|
Widget buildAppTabView(
|
||||||
|
AppTab tab,
|
||||||
|
ScrollController controller, {
|
||||||
|
PreferredSizeWidget? topBar,
|
||||||
|
}) {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case AppTab.files:
|
case AppTab.files:
|
||||||
// Keyed so the Files and Offline tabs (same widget type) never share
|
// Keyed so the Files and Offline tabs (same widget type) never share
|
||||||
@@ -19,24 +32,26 @@ Widget buildAppTabView(AppTab tab, ScrollController controller) {
|
|||||||
return FilesView(
|
return FilesView(
|
||||||
key: const ValueKey('files'),
|
key: const ValueKey('files'),
|
||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
|
topBar: topBar,
|
||||||
);
|
);
|
||||||
case AppTab.photos:
|
case AppTab.photos:
|
||||||
return PhotosView(scrollController: controller);
|
return PhotosView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.favorites:
|
case AppTab.favorites:
|
||||||
return FavoritesView(scrollController: controller);
|
return FavoritesView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.activity:
|
case AppTab.activity:
|
||||||
return ActivityView(scrollController: controller);
|
return ActivityView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.trash:
|
case AppTab.trash:
|
||||||
return TrashView(scrollController: controller);
|
return TrashView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.shares:
|
case AppTab.shares:
|
||||||
return SharesView(scrollController: controller);
|
return SharesView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.recent:
|
case AppTab.recent:
|
||||||
return RecentView(scrollController: controller);
|
return RecentView(scrollController: controller, topBar: topBar);
|
||||||
case AppTab.offline:
|
case AppTab.offline:
|
||||||
return FilesView(
|
return FilesView(
|
||||||
key: const ValueKey('offline'),
|
key: const ValueKey('offline'),
|
||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
offline: true,
|
offline: true,
|
||||||
|
topBar: topBar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
|
import 'avatar_menu.dart';
|
||||||
import 'create_menu.dart';
|
import 'create_menu.dart';
|
||||||
import 'noo/nav/noo_top_bar.dart';
|
import 'noo/nav/noo_top_bar.dart';
|
||||||
import 'shell/shell_common.dart';
|
import 'shell/shell_common.dart';
|
||||||
@@ -13,19 +14,40 @@ import 'shell/shell_common.dart';
|
|||||||
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
|
/// - iOS: `menu` leading, `plus` (Files only - no tab besides Files has a
|
||||||
/// create/upload flow) and the account avatar trailing, then the large
|
/// create/upload flow) and the account avatar trailing, then the large
|
||||||
/// title, then an inline search field that opens [SearchView].
|
/// title, then an inline search field that opens [SearchView].
|
||||||
/// - Android: `menu`, title, `search` and avatar actions. Upload has no
|
/// - Android: `menu`, title, avatar actions. Upload has no top-bar icon
|
||||||
/// top-bar icon here - it's the [NooFab] `MainShellView` shows instead.
|
/// here - it's the [NooFab] `MainShellView` shows instead. When search
|
||||||
|
/// isn't in the bottom bar, an inline search-field-styled launcher
|
||||||
|
/// (`androidTitleTrailing`) sits to the title's own right, rather than a
|
||||||
|
/// bare search icon over in `actions`.
|
||||||
///
|
///
|
||||||
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
|
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
|
||||||
/// read from context here, since [preferredSize] is a plain getter with no
|
/// read from context here, since [preferredSize] is a plain getter with no
|
||||||
/// BuildContext - the same reason [NooTopBar] itself takes it as a field.
|
/// BuildContext - the same reason [NooTopBar] itself takes it as a field.
|
||||||
|
/// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in
|
||||||
|
/// for the same reason: it hides this bar's own search field/icon so
|
||||||
|
/// there's only ever one search entry point on screen once the bottom bar
|
||||||
|
/// has taken it over. [navMenuStyle] is
|
||||||
|
/// [SettingsController.navMenuStyle]: [NooNavMenuStyle.drawer] wires the
|
||||||
|
/// `menu` icon to `Scaffold.of(context).openDrawer()` as before;
|
||||||
|
/// [NooNavMenuStyle.avatarMenu] drops that icon (`onMenu: null`) and makes
|
||||||
|
/// the avatar button open `showAvatarMenu` instead of the account
|
||||||
|
/// switcher.
|
||||||
class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
final AppTab tab;
|
final AppTab tab;
|
||||||
|
final bool searchInBottomBar;
|
||||||
|
final NooNavMenuStyle navMenuStyle;
|
||||||
|
|
||||||
const AppTopBar({super.key, required this.style, required this.tab});
|
const AppTopBar({
|
||||||
|
super.key,
|
||||||
|
required this.style,
|
||||||
|
required this.tab,
|
||||||
|
this.searchInBottomBar = false,
|
||||||
|
this.navMenuStyle = NooNavMenuStyle.drawer,
|
||||||
|
});
|
||||||
|
|
||||||
bool get _ios => style == NooNavStyle.ios;
|
bool get _ios => style == NooNavStyle.ios;
|
||||||
|
bool get _showSearch => !searchInBottomBar;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => NooTopBar(
|
Size get preferredSize => NooTopBar(
|
||||||
@@ -34,7 +56,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
// A throwaway placeholder purely to make preferredSize's "is there a
|
// A throwaway placeholder purely to make preferredSize's "is there a
|
||||||
// search slot" check match what build() actually shows - never
|
// search slot" check match what build() actually shows - never
|
||||||
// inserted into the tree, so its content doesn't matter.
|
// inserted into the tree, so its content doesn't matter.
|
||||||
search: _ios ? const SizedBox.shrink() : null,
|
search: _ios && _showSearch ? const SizedBox.shrink() : null,
|
||||||
).preferredSize;
|
).preferredSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -42,23 +64,33 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
return NooTopBar(
|
return NooTopBar(
|
||||||
style: style,
|
style: style,
|
||||||
title: tab.label,
|
title: tab.label,
|
||||||
onMenu: () => Scaffold.of(context).openDrawer(),
|
onMenu: navMenuStyle == NooNavMenuStyle.drawer
|
||||||
|
? () => Scaffold.of(context).openDrawer()
|
||||||
|
: null,
|
||||||
|
// Android has no large title to put a second search row under the
|
||||||
|
// way iOS does (`search:` below) - an inline bar sits beside the
|
||||||
|
// title instead, rather than a plain search icon over in `actions`.
|
||||||
|
androidTitleTrailing: !_ios && _showSearch
|
||||||
|
? const ShellSearchLauncher()
|
||||||
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
if (!_ios)
|
|
||||||
NooTopBarButton(
|
|
||||||
icon: LucideIcons.search,
|
|
||||||
tooltip: 'Search',
|
|
||||||
onTap: () => openSearch(context),
|
|
||||||
),
|
|
||||||
if (_ios && tab == AppTab.files)
|
if (_ios && tab == AppTab.files)
|
||||||
NooTopBarButton(
|
NooTopBarButton(
|
||||||
icon: LucideIcons.plus,
|
icon: LucideIcons.plus,
|
||||||
tooltip: 'Create',
|
tooltip: 'Create',
|
||||||
onTap: () => showCreateMenu(context),
|
onTap: () => showCreateMenu(context),
|
||||||
),
|
),
|
||||||
ShellAvatarButton(hitBox: _ios ? 44 : 48),
|
ShellAvatarButton(
|
||||||
|
hitBox: _ios ? 44 : 48,
|
||||||
|
onTap: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||||
|
? () => showAvatarMenu(context)
|
||||||
|
: null,
|
||||||
|
label: navMenuStyle == NooNavMenuStyle.avatarMenu
|
||||||
|
? 'Menu'
|
||||||
|
: 'Accounts',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
search: _ios ? const ShellSearchLauncher(ios: true) : null,
|
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/app_tab.dart';
|
||||||
|
import '../providers/session_controller.dart';
|
||||||
|
import '../providers/settings_controller.dart';
|
||||||
|
import '../providers/trash_controller.dart';
|
||||||
|
import '../theme/design_tokens.dart';
|
||||||
|
import 'noo/core/noo_avatar.dart';
|
||||||
|
import '../views/login_view.dart';
|
||||||
|
import 'noo/core/noo_badge.dart';
|
||||||
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
|
import 'shell/shell_common.dart';
|
||||||
|
|
||||||
|
/// The dropdown [ShellAvatarButton] opens when
|
||||||
|
/// [SettingsController.navMenuStyle] is [NooNavMenuStyle.avatarMenu] -
|
||||||
|
/// everything [AppDrawer] holds beyond its storage bar (hidden tabs,
|
||||||
|
/// Settings), reached from the avatar instead of a hamburger-triggered
|
||||||
|
/// left `Drawer`. Built with `showGeneralDialog` rather than a `Drawer`/
|
||||||
|
/// `showNooSheet`/`showNooDialog` - this needs a transparent (non-dimming)
|
||||||
|
/// barrier and a card anchored under the top bar rather than a modal
|
||||||
|
/// sheet/dialog, and there's no existing anchored-popup primitive in this
|
||||||
|
/// app to reuse (`PopupMenuButton`'s own width doesn't stretch to the full
|
||||||
|
/// content column the way this needs to).
|
||||||
|
Future<void> showAvatarMenu(BuildContext context) {
|
||||||
|
return showGeneralDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
barrierDismissible: true,
|
||||||
|
barrierLabel: 'Close menu',
|
||||||
|
transitionDuration: NooMotion.fast,
|
||||||
|
pageBuilder: (context, _, _) => const _AvatarMenuContent(),
|
||||||
|
transitionBuilder: (context, animation, _, child) => FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: ScaleTransition(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
scale: Tween<double>(
|
||||||
|
begin: 0.96,
|
||||||
|
end: 1,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: NooMotion.ease)),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarMenuContent extends StatefulWidget {
|
||||||
|
const _AvatarMenuContent();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
|
||||||
|
bool _expanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final session = context.watch<SessionController>();
|
||||||
|
final settings = context.watch<SettingsController>();
|
||||||
|
final trashCount = context.watch<TrashController>().items.length;
|
||||||
|
final hiddenTabs = settings.tabOrder
|
||||||
|
.where((t) => settings.hiddenTabs.contains(t))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
void closeAndOpenSettings() {
|
||||||
|
Navigator.pop(context);
|
||||||
|
openSettings(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
// Just the status-bar inset, not the top bar's own height on top of
|
||||||
|
// it - the card covers the top bar (title included) rather than
|
||||||
|
// sitting below it, so opening it reads as the avatar growing into
|
||||||
|
// this instead of a separate element appearing underneath the row
|
||||||
|
// it came from.
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: NooSpace.md),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Container(
|
||||||
|
// No `color`/`clipBehavior` here - a `Container` with both a
|
||||||
|
// `border` and `clipBehavior` set paints the border as part
|
||||||
|
// of its *outer* decoration, then paints its (clipped) child
|
||||||
|
// on top right up to that same boundary with no gap for the
|
||||||
|
// border's own stroke width to show through. An opaque child
|
||||||
|
// touching that edge - every row below has its own full-bleed
|
||||||
|
// `Material` fill - then paints straight over the inner half
|
||||||
|
// of the border, which read as the border going missing
|
||||||
|
// specifically wherever an opaque row sits (every corner but
|
||||||
|
// the two by the header, which has no opaque fill of its
|
||||||
|
// own). The 1px `Padding` + inset `ClipRRect` below keeps the
|
||||||
|
// clipped, filled content entirely inside the border's own
|
||||||
|
// stroke instead of racing it for the same pixels.
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
// `nooDialogShadow` alone is a wide, soft, fairly faint
|
||||||
|
// shadow - built for a desktop dialog with plenty of room
|
||||||
|
// to fall off into. On a small card over a dark theme's
|
||||||
|
// near-black `bg`, that falloff is too gradual to read as
|
||||||
|
// elevation at all (a dark shadow needs real density close
|
||||||
|
// to the edge to be visible against an already-dark
|
||||||
|
// backdrop). A second, tighter, more opaque contact shadow
|
||||||
|
// underneath it gives an immediate value-step right at the
|
||||||
|
// card's edge in both themes, with the soft one still
|
||||||
|
// doing the wider ambient falloff on top.
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x40000000),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
nooDialogShadow,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(1),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.card - 1),
|
||||||
|
child: Material(
|
||||||
|
color: colors.surface,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Avatar on the right, name/host on the left - mirrors the
|
||||||
|
// top bar's own right-aligned avatar exactly, so this
|
||||||
|
// header reads as a continuation of the button that
|
||||||
|
// opened it rather than a disconnected card.
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setState(() => _expanded = !_expanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
session.username,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.bodyL.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
serverHost(session.serverUrl),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.meta.copyWith(
|
||||||
|
color: colors.fg3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
NooAvatar(
|
||||||
|
initials: accountInitial(session.username),
|
||||||
|
current: true,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colors.fg1.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_expanded
|
||||||
|
? LucideIcons.chevronUp
|
||||||
|
: LucideIcons.chevronDown,
|
||||||
|
size: 20,
|
||||||
|
color: colors.fg2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSize(
|
||||||
|
duration: NooMotion.fast,
|
||||||
|
curve: NooMotion.ease,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: _expanded
|
||||||
|
? Column(
|
||||||
|
children: [
|
||||||
|
for (final account in session.accounts)
|
||||||
|
if (account.id != session.activeAccountId)
|
||||||
|
_OtherAccountRow(
|
||||||
|
name: account.username,
|
||||||
|
host: serverHost(account.serverUrl),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
session.switchAccount(account.id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.md,
|
||||||
|
0,
|
||||||
|
NooSpace.md,
|
||||||
|
NooSpace.md,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _AccountButton(
|
||||||
|
icon: LucideIcons.userPlus,
|
||||||
|
label: 'Add Account',
|
||||||
|
onTap: () {
|
||||||
|
final nav = Navigator.of(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
nav.pop();
|
||||||
|
nav.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const LoginView(
|
||||||
|
isAddingAccount: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _AccountButton(
|
||||||
|
icon: LucideIcons.users,
|
||||||
|
label: 'Manage Accounts',
|
||||||
|
onTap: closeAndOpenSettings,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: const SizedBox(width: double.infinity),
|
||||||
|
),
|
||||||
|
if (hiddenTabs.isNotEmpty) ...[
|
||||||
|
Divider(height: 1, color: colors.line),
|
||||||
|
for (final tab in hiddenTabs)
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: tab.icon,
|
||||||
|
label: Text(tab.label),
|
||||||
|
trailing: tab == AppTab.trash && trashCount > 0
|
||||||
|
? NooBadge(
|
||||||
|
tone: NooBadgeTone.accent,
|
||||||
|
child: Text('$trashCount'),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
settings.requestTab(tab);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
Divider(height: 1, color: colors.line),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.settings,
|
||||||
|
label: const Text('Settings'),
|
||||||
|
onTap: closeAndOpenSettings,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OtherAccountRow extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String host;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _OtherAccountRow({
|
||||||
|
required this.name,
|
||||||
|
required this.host,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(NooSpace.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.bodyL.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
host,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
||||||
|
const SizedBox(width: 44),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _AccountButton({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
final radius = BorderRadius.circular(NooRadii.input);
|
||||||
|
return Material(
|
||||||
|
color: colors.bg,
|
||||||
|
borderRadius: radius,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: radius,
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: colors.fg2),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../models/app_tab.dart';
|
import '../models/app_tab.dart';
|
||||||
import 'noo/nav/noo_bottom_bar.dart';
|
import 'noo/nav/noo_bottom_bar.dart';
|
||||||
|
|
||||||
@@ -7,30 +8,44 @@ import 'noo/nav/noo_bottom_bar.dart';
|
|||||||
/// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no
|
/// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no
|
||||||
/// destination highlighted) when the shell is showing a tab opened from the
|
/// destination highlighted) when the shell is showing a tab opened from the
|
||||||
/// drawer's "More" list that isn't one of the pinned [tabs].
|
/// drawer's "More" list that isn't one of the pinned [tabs].
|
||||||
|
///
|
||||||
|
/// [onSearchTap] (from Settings' "Search in bottom bar") adds the Search
|
||||||
|
/// entry [NooBottomBar] itself knows how to place (row's last item when
|
||||||
|
/// attached, its own satellite when floating) - null leaves the bar
|
||||||
|
/// exactly as before.
|
||||||
class BottomNavBar extends StatelessWidget {
|
class BottomNavBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
|
final NooBottomBarStyle barStyle;
|
||||||
final List<AppTab> tabs;
|
final List<AppTab> tabs;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
|
final VoidCallback? onSearchTap;
|
||||||
|
|
||||||
const BottomNavBar({
|
const BottomNavBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
|
this.onSearchTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return NooBottomBar(
|
return NooBottomBar(
|
||||||
style: style,
|
style: style,
|
||||||
|
barStyle: barStyle,
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final tab in tabs)
|
for (final tab in tabs)
|
||||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||||
],
|
],
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onSelected: onDestinationSelected,
|
onSelected: onDestinationSelected,
|
||||||
|
searchDestination: onSearchTap == null
|
||||||
|
? null
|
||||||
|
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
|
||||||
|
onSearchTap: onSearchTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ enum _DetailsTab { info, versions, activity }
|
|||||||
/// and the "open externally" flow for unsupported file types.
|
/// and the "open externally" flow for unsupported file types.
|
||||||
///
|
///
|
||||||
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
|
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
|
||||||
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog],
|
/// desktop path in [show] passes [DetailsBody] straight to [showNooDialog],
|
||||||
/// which renders the header itself via `leading`/`title`/`subtitle`.
|
/// which renders the header itself via `leading`/`title`/`subtitle`.
|
||||||
class DetailsSheet extends StatelessWidget {
|
class DetailsSheet extends StatelessWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
@@ -68,7 +68,7 @@ class DetailsSheet extends StatelessWidget {
|
|||||||
leading: detailsFileTile(item),
|
leading: detailsFileTile(item),
|
||||||
title: item.name,
|
title: item.name,
|
||||||
subtitle: detailsMetaLine(item),
|
subtitle: detailsMetaLine(item),
|
||||||
children: [_DetailsBody(item: item)],
|
children: [DetailsBody(item: item)],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
||||||
@@ -86,7 +86,7 @@ class DetailsSheet extends StatelessWidget {
|
|||||||
onClose: () => Navigator.pop(context),
|
onClose: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: NooSpace.lg),
|
const SizedBox(height: NooSpace.lg),
|
||||||
_DetailsBody(item: item),
|
DetailsBody(item: item),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,16 +102,16 @@ class DetailsSheet extends StatelessWidget {
|
|||||||
/// guard), so switching tabs via a bare `switch` on the selected type would
|
/// guard), so switching tabs via a bare `switch` on the selected type would
|
||||||
/// tear down and rebuild whichever tab isn't showing, discarding its
|
/// tear down and rebuild whichever tab isn't showing, discarding its
|
||||||
/// fetched data and re-requesting it every time the user switched back.
|
/// fetched data and re-requesting it every time the user switched back.
|
||||||
class _DetailsBody extends StatefulWidget {
|
class DetailsBody extends StatefulWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
|
|
||||||
const _DetailsBody({required this.item});
|
const DetailsBody({super.key, required this.item});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_DetailsBody> createState() => _DetailsBodyState();
|
State<DetailsBody> createState() => _DetailsBodyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DetailsBodyState extends State<_DetailsBody> {
|
class _DetailsBodyState extends State<DetailsBody> {
|
||||||
_DetailsTab _tab = _DetailsTab.info;
|
_DetailsTab _tab = _DetailsTab.info;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
|
|
||||||
|
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
|
||||||
|
final BorderRadius? radius;
|
||||||
|
|
||||||
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
|
||||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
/// explicit value (e.g. from user settings) to make it adjustable.
|
||||||
final double blurSigma;
|
final double blurSigma;
|
||||||
@@ -35,6 +38,7 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
this.borderRadius = 0,
|
this.borderRadius = 0,
|
||||||
|
this.radius,
|
||||||
this.blurSigma = 20,
|
this.blurSigma = 20,
|
||||||
this.opacity = 0.8,
|
this.opacity = 0.8,
|
||||||
this.color,
|
this.color,
|
||||||
@@ -42,15 +46,16 @@ class FrostedGlassContainer extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||||
final tint = color ?? context.nooColors.surface;
|
final tint = color ?? context.nooColors.surface;
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: shape,
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: tint.withValues(alpha: opacity),
|
color: tint.withValues(alpha: opacity),
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: shape,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../../../theme/design_tokens.dart';
|
import '../../../theme/design_tokens.dart';
|
||||||
|
import '../nav/noo_nav_style.dart';
|
||||||
|
|
||||||
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
|
/// Android-only extended Upload pill, bottom-right of Files and Photos (Noo
|
||||||
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent
|
/// Design System project, `components/core/FAB.jsx`). 56px tall, accent
|
||||||
@@ -10,11 +11,21 @@ import '../../../theme/design_tokens.dart';
|
|||||||
/// don't support upload, so the button stays in place and resizes as the
|
/// don't support upload, so the button stays in place and resizes as the
|
||||||
/// user switches tabs instead of the Scaffold popping it fully in/out -
|
/// user switches tabs instead of the Scaffold popping it fully in/out -
|
||||||
/// the label's width animates away rather than the FAB disappearing.
|
/// the label's width animates away rather than the FAB disappearing.
|
||||||
|
///
|
||||||
|
/// [barStyle] shapes the corners to match the bottom bar it sits above
|
||||||
|
/// (Settings > Appearance). With [NooBottomBarStyle.floating] the FAB stays
|
||||||
|
/// fully round (`NooRadii.pill`) - consistent with that mode's other
|
||||||
|
/// elements, which are already rounder (the bar itself at 28px, its search
|
||||||
|
/// satellite a full circle). With [NooBottomBarStyle.attached] the FAB
|
||||||
|
/// squares off to `NooRadii.card`, a rounded square that fits attached's
|
||||||
|
/// flatter, edge-to-edge aesthetic; this applies whether [collapsed] or not,
|
||||||
|
/// so the FAB keeps one consistent identity per bar style.
|
||||||
class NooFab extends StatelessWidget {
|
class NooFab extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final bool collapsed;
|
final bool collapsed;
|
||||||
|
final NooBottomBarStyle barStyle;
|
||||||
|
|
||||||
const NooFab({
|
const NooFab({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -22,17 +33,21 @@ class NooFab extends StatelessWidget {
|
|||||||
this.icon = LucideIcons.plus,
|
this.icon = LucideIcons.plus,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.collapsed = false,
|
this.collapsed = false,
|
||||||
|
required this.barStyle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
|
final radius = BorderRadius.circular(
|
||||||
|
barStyle == NooBottomBarStyle.attached ? NooRadii.card : NooRadii.pill,
|
||||||
|
);
|
||||||
return Material(
|
return Material(
|
||||||
color: colors.accent,
|
color: colors.accent,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
borderRadius: radius,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(NooRadii.pill),
|
borderRadius: radius,
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: NooMotion.base,
|
duration: NooMotion.base,
|
||||||
curve: NooMotion.ease,
|
curve: NooMotion.ease,
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final VoidCallback? onSecondaryTap;
|
final VoidCallback? onSecondaryTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
/// Overflow menu tap. The button is hidden when null.
|
/// Overflow menu tap. The button is hidden when null.
|
||||||
final VoidCallback? onMore;
|
final VoidCallback? onMore;
|
||||||
@@ -171,6 +172,7 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
this.onTap,
|
this.onTap,
|
||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.onSecondaryTap,
|
this.onSecondaryTap,
|
||||||
|
this.onLongPress,
|
||||||
this.onMore,
|
this.onMore,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +195,7 @@ class NooFileTableRow extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
onDoubleTap: onDoubleTap,
|
onDoubleTap: onDoubleTap,
|
||||||
onSecondaryTap: onSecondaryTap,
|
onSecondaryTap: onSecondaryTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: NooSizes.rowDesktop,
|
height: NooSizes.rowDesktop,
|
||||||
child: _TableColumns(
|
child: _TableColumns(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.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';
|
||||||
|
|
||||||
@@ -34,6 +35,13 @@ class NooSwipeActionSpec {
|
|||||||
/// 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
|
||||||
/// open just closes it.
|
/// open just closes it.
|
||||||
|
///
|
||||||
|
/// Dragging further still - past [_kTriggerExtent] of the block's own
|
||||||
|
/// width, the block growing to fill the extra reveal so there's no gap -
|
||||||
|
/// arms the action (a [HapticFeedback.mediumImpact] marks the crossing);
|
||||||
|
/// releasing while armed fires it immediately instead of just leaving it
|
||||||
|
/// open, so a single swipe-through gesture can do the whole thing without a
|
||||||
|
/// second tap.
|
||||||
class NooSwipeAction extends StatefulWidget {
|
class NooSwipeAction extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|
||||||
@@ -59,12 +67,24 @@ class NooSwipeAction extends StatefulWidget {
|
|||||||
|
|
||||||
class _NooSwipeActionState extends State<NooSwipeAction>
|
class _NooSwipeActionState extends State<NooSwipeAction>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
|
/// How far past the fully-revealed block (1.0) a drag has to go before
|
||||||
|
/// releasing fires the action outright rather than just snapping open -
|
||||||
|
/// "swipe through" in one gesture instead of open-then-tap.
|
||||||
|
static const double _kTriggerExtent = 1.8;
|
||||||
|
|
||||||
|
/// Hard ceiling on the controller's value - past [_kTriggerExtent] there's
|
||||||
|
/// nothing more for further drag to *do*, but a little extra travel still
|
||||||
|
/// gives the gesture room to keep moving under the finger instead of
|
||||||
|
/// hitting a dead stop right at the trigger point.
|
||||||
|
static const double _kMaxDrag = 2.4;
|
||||||
|
|
||||||
/// -1 = end action fully revealed, 0 = closed, 1 = start action revealed
|
/// -1 = end action fully revealed, 0 = closed, 1 = start action revealed
|
||||||
/// (in visual left/right terms after [_dir] is applied).
|
/// (in visual left/right terms after [_dir] is applied) - and on past
|
||||||
|
/// either bound up to [_kMaxDrag] while armed for [_kTriggerExtent].
|
||||||
late final AnimationController _ctrl = AnimationController(
|
late final AnimationController _ctrl = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
lowerBound: -1,
|
lowerBound: -_kMaxDrag,
|
||||||
upperBound: 1,
|
upperBound: _kMaxDrag,
|
||||||
value: 0,
|
value: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -85,13 +105,21 @@ class _NooSwipeActionState extends State<NooSwipeAction>
|
|||||||
void _onDragUpdate(DragUpdateDetails d) {
|
void _onDragUpdate(DragUpdateDetails d) {
|
||||||
// Positive = revealing the start action.
|
// Positive = revealing the start action.
|
||||||
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
|
final delta = d.primaryDelta! * _dir / NooSwipeAction.actionWidth;
|
||||||
final min = widget.endAction != null ? -1.0 : 0.0;
|
final min = widget.endAction != null ? -_kMaxDrag : 0.0;
|
||||||
final max = widget.startAction != null ? 1.0 : 0.0;
|
final max = widget.startAction != null ? _kMaxDrag : 0.0;
|
||||||
_ctrl.value = (_ctrl.value + delta).clamp(min, max);
|
final next = (_ctrl.value + delta).clamp(min, max);
|
||||||
|
final wasArmed = _ctrl.value.abs() >= _kTriggerExtent;
|
||||||
|
final nowArmed = next.abs() >= _kTriggerExtent;
|
||||||
|
if (nowArmed && !wasArmed) HapticFeedback.mediumImpact();
|
||||||
|
_ctrl.value = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(DragEndDetails d) {
|
void _onDragEnd(DragEndDetails d) {
|
||||||
final v = _ctrl.value;
|
final v = _ctrl.value;
|
||||||
|
if (v.abs() >= _kTriggerExtent) {
|
||||||
|
_trigger(v > 0 ? widget.startAction! : widget.endAction!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final velocity = d.primaryVelocity! * _dir;
|
final velocity = d.primaryVelocity! * _dir;
|
||||||
double target;
|
double target;
|
||||||
if (velocity.abs() > 700) {
|
if (velocity.abs() > 700) {
|
||||||
@@ -141,6 +169,13 @@ class _NooSwipeActionState extends State<NooSwipeAction>
|
|||||||
child: _ActionBlock(
|
child: _ActionBlock(
|
||||||
spec: showing,
|
spec: showing,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
|
// Grows past its own min width to fill the extra
|
||||||
|
// reveal once dragged further than a plain "open"
|
||||||
|
// - otherwise the row's translated edge would pull
|
||||||
|
// away from the block and expose bare space behind
|
||||||
|
// it.
|
||||||
|
width: v.abs() * NooSwipeAction.actionWidth,
|
||||||
|
armed: v.abs() >= _kTriggerExtent,
|
||||||
onTap: () => _trigger(showing),
|
onTap: () => _trigger(showing),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -196,11 +231,24 @@ class _OpenAwareChild extends AnimatedWidget {
|
|||||||
class _ActionBlock extends StatelessWidget {
|
class _ActionBlock extends StatelessWidget {
|
||||||
final NooSwipeActionSpec spec;
|
final NooSwipeActionSpec spec;
|
||||||
final NooColors colors;
|
final NooColors colors;
|
||||||
|
|
||||||
|
/// The block's own width - grows past [NooSwipeAction.actionWidth] once
|
||||||
|
/// dragged further than a plain reveal, so it always fills exactly what's
|
||||||
|
/// exposed behind the row.
|
||||||
|
final double width;
|
||||||
|
|
||||||
|
/// True once the drag has gone far enough that releasing now fires the
|
||||||
|
/// action - bumps the icon up a touch as a "you're past the point of no
|
||||||
|
/// return" cue, on top of the haptic tick that fired at the same moment.
|
||||||
|
final bool armed;
|
||||||
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _ActionBlock({
|
const _ActionBlock({
|
||||||
required this.spec,
|
required this.spec,
|
||||||
required this.colors,
|
required this.colors,
|
||||||
|
required this.width,
|
||||||
|
required this.armed,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -216,13 +264,18 @@ class _ActionBlock extends StatelessWidget {
|
|||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: NooSwipeAction.actionWidth,
|
width: width.clamp(NooSwipeAction.actionWidth, double.infinity),
|
||||||
color: bg,
|
color: bg,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: Colors.white),
|
AnimatedScale(
|
||||||
|
scale: armed ? 1.15 : 1,
|
||||||
|
duration: NooMotion.fast,
|
||||||
|
curve: NooMotion.ease,
|
||||||
|
child: Icon(icon, size: 20, color: Colors.white),
|
||||||
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class NooGroupedList extends StatefulWidget {
|
|||||||
final String? aside;
|
final String? aside;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
final Widget? footer;
|
final Widget? footer;
|
||||||
|
|
||||||
|
/// Shown between [label] and the card (e.g. a `NooInfoNote`).
|
||||||
|
final Widget? notice;
|
||||||
final bool collapsible;
|
final bool collapsible;
|
||||||
final bool initiallyExpanded;
|
final bool initiallyExpanded;
|
||||||
|
|
||||||
@@ -31,6 +34,7 @@ class NooGroupedList extends StatefulWidget {
|
|||||||
this.aside,
|
this.aside,
|
||||||
required this.children,
|
required this.children,
|
||||||
this.footer,
|
this.footer,
|
||||||
|
this.notice,
|
||||||
this.collapsible = false,
|
this.collapsible = false,
|
||||||
this.initiallyExpanded = true,
|
this.initiallyExpanded = true,
|
||||||
});
|
});
|
||||||
@@ -58,6 +62,10 @@ class _NooGroupedListState extends State<NooGroupedList> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
if (widget.notice != null) ...[
|
||||||
|
widget.notice!,
|
||||||
|
const SizedBox(height: NooSpace.md),
|
||||||
|
],
|
||||||
AnimatedCrossFade(
|
AnimatedCrossFade(
|
||||||
duration: NooMotion.fast,
|
duration: NooMotion.fast,
|
||||||
sizeCurve: NooMotion.ease,
|
sizeCurve: NooMotion.ease,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import '../../../theme/design_tokens.dart';
|
||||||
|
|
||||||
|
/// Tinted info callout (info-soft fill, info icon) for a short notice that
|
||||||
|
/// must be noticed immediately, unlike a muted footer/subtitle line.
|
||||||
|
class NooInfoNote extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const NooInfoNote({super.key, required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.infoSoft,
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||||
|
border: Border.all(color: colors.info.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 1),
|
||||||
|
child: Icon(LucideIcons.info, size: 18, color: colors.info),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: NooText.body.copyWith(color: colors.fg1, height: 1.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ import 'noo_nav_style.dart';
|
|||||||
export 'noo_nav_style.dart';
|
export 'noo_nav_style.dart';
|
||||||
|
|
||||||
/// Android indicator geometry, shared between the sliding pill and each
|
/// Android indicator geometry, shared between the sliding pill and each
|
||||||
/// item's own icon box so they line up exactly.
|
/// item's own icon box so they line up exactly. Floating's own top offset
|
||||||
const double _kAndroidPillTop = 14;
|
/// is shorter than attached's - its row is 16px shorter overall (64 vs 80)
|
||||||
|
/// and there's no edge-to-edge safe-area strip inside it eating into that.
|
||||||
|
const double _kAttachedPillTop = 14;
|
||||||
|
const double _kFloatingPillTop = 8;
|
||||||
const double _kAndroidPillWidth = 56;
|
const double _kAndroidPillWidth = 56;
|
||||||
const double _kAndroidPillHeight = 32;
|
const double _kAndroidPillHeight = 32;
|
||||||
|
|
||||||
@@ -23,29 +26,133 @@ const double _kAndroidPillHeight = 32;
|
|||||||
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
||||||
/// rather than popping in/out on the destination item itself.
|
/// rather than popping in/out on the destination item itself.
|
||||||
///
|
///
|
||||||
/// The home indicator / gesture bar area below the row comes from the
|
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||||
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
|
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||||
/// right on every device. Meant for `Scaffold.bottomNavigationBar`.
|
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||||
|
/// corners, a 1px `line` border plus [nooDialogShadow] (the one shadow the
|
||||||
|
/// rest of the app allows itself, see that constant's own doc comment) so
|
||||||
|
/// it actually reads as floating above the content scrolling behind it.
|
||||||
|
/// Floating's Android row is both shorter (64 vs 80) and drops its idle
|
||||||
|
/// tabs' reserved label space - with no label to leave room for, an idle
|
||||||
|
/// icon just centers in the whole button, rather than sitting high with a
|
||||||
|
/// gap under it. The home indicator / gesture bar area below the row comes
|
||||||
|
/// from the bottom safe-area inset rather than a fixed 34/20px spacer, so
|
||||||
|
/// it's right on every device. Meant for `Scaffold.bottomNavigationBar` -
|
||||||
|
/// the host `Scaffold` needs `extendBody: true` while floating, and a
|
||||||
|
/// scrollable body needs enough trailing padding to clear the bar's own
|
||||||
|
/// footprint ([NooBottomBar.rowHeight] + [NooBottomBar.floatingBottomMargin]
|
||||||
|
/// + the bottom safe area) since nothing does that automatically once the
|
||||||
|
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||||
|
/// `bottomBarClearance`.
|
||||||
|
///
|
||||||
|
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||||
|
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||||
|
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||||
|
/// `SearchView`, it doesn't select anything) - the row's last item when
|
||||||
|
/// attached, or its own satellite circle beside the bar when floating,
|
||||||
|
/// always fully round regardless of the bar's own corner radius.
|
||||||
class NooBottomBar extends StatelessWidget {
|
class NooBottomBar extends StatelessWidget {
|
||||||
final NooNavStyle style;
|
final NooNavStyle style;
|
||||||
|
final NooBottomBarStyle barStyle;
|
||||||
final List<NooNavDestination> destinations;
|
final List<NooNavDestination> destinations;
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
|
final NooNavDestination? searchDestination;
|
||||||
|
final VoidCallback? onSearchTap;
|
||||||
|
|
||||||
const NooBottomBar({
|
const NooBottomBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.style,
|
required this.style,
|
||||||
|
this.barStyle = NooBottomBarStyle.attached,
|
||||||
required this.destinations,
|
required this.destinations,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
|
this.searchDestination,
|
||||||
|
this.onSearchTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The row height [build] draws for [style]/[barStyle] - exposed so a
|
||||||
|
/// scrollable body sharing the same `Scaffold` can reserve exactly this
|
||||||
|
/// much clearance (see the class doc comment) instead of guessing.
|
||||||
|
static double rowHeight(NooNavStyle style, NooBottomBarStyle barStyle) {
|
||||||
|
if (style == NooNavStyle.ios) return 50;
|
||||||
|
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||||
|
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||||
|
static const double floatingBottomMargin = 12;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final ios = style == NooNavStyle.ios;
|
final ios = style == NooNavStyle.ios;
|
||||||
|
final floating = barStyle == NooBottomBarStyle.floating;
|
||||||
|
final hasSearch = searchDestination != null && onSearchTap != null;
|
||||||
|
// Attached folds Search into the row itself (last item); floating
|
||||||
|
// gives it a separate satellite circle instead (built below), so the
|
||||||
|
// row builders only ever see it as a trailing item in the former case.
|
||||||
|
final rowSearch = hasSearch && !floating ? searchDestination : null;
|
||||||
|
|
||||||
final row = ios ? _buildIosRow() : _buildAndroidRow(colors);
|
final row = ios
|
||||||
|
? _buildIosRow(trailingSearch: rowSearch)
|
||||||
|
: _buildAndroidRow(
|
||||||
|
colors,
|
||||||
|
floating: floating,
|
||||||
|
trailingSearch: rowSearch,
|
||||||
|
);
|
||||||
|
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||||
|
|
||||||
|
if (floating) {
|
||||||
|
final pill = Container(
|
||||||
|
height: barHeight,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surface,
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
boxShadow: const [nooDialogShadow],
|
||||||
|
),
|
||||||
|
child: row,
|
||||||
|
);
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
16,
|
||||||
|
0,
|
||||||
|
16,
|
||||||
|
NooBottomBar.floatingBottomMargin,
|
||||||
|
),
|
||||||
|
child: hasSearch
|
||||||
|
// A fixed-height SizedBox, not just a Row with
|
||||||
|
// crossAxisAlignment.stretch - the bottomNavigationBar slot
|
||||||
|
// gives this widget a *loose* (unbounded-max) height
|
||||||
|
// constraint, and stretch on an unbounded cross axis blows up
|
||||||
|
// to fill the screen in profile/release (the assertion that
|
||||||
|
// would catch it in debug is stripped there) - the same
|
||||||
|
// silent-oversizing bug files_controls_row.dart hit earlier
|
||||||
|
// from a different cause. Both children already size
|
||||||
|
// themselves explicitly (pill's own `height`, the
|
||||||
|
// satellite's `size`), so stretch was never actually needed.
|
||||||
|
? SizedBox(
|
||||||
|
height: barHeight,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: pill),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_SearchSatellite(
|
||||||
|
destination: searchDestination!,
|
||||||
|
onTap: onSearchTap!,
|
||||||
|
size: barHeight,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: pill,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -54,12 +161,12 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: SizedBox(height: ios ? 50 : 80, child: row),
|
child: SizedBox(height: barHeight, child: row),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIosRow() {
|
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -71,20 +178,34 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
onTap: () => onSelected(i),
|
onTap: () => onSelected(i),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (trailingSearch != null)
|
||||||
|
Expanded(
|
||||||
|
child: _IosItem(
|
||||||
|
destination: trailingSearch,
|
||||||
|
selected: false,
|
||||||
|
onTap: onSearchTap!,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAndroidRow(NooColors colors) {
|
Widget _buildAndroidRow(
|
||||||
|
NooColors colors, {
|
||||||
|
required bool floating,
|
||||||
|
NooNavDestination? trailingSearch,
|
||||||
|
}) {
|
||||||
|
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
|
||||||
|
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final itemWidth = constraints.maxWidth / destinations.length;
|
final itemWidth = constraints.maxWidth / itemCount;
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: NooMotion.base,
|
duration: NooMotion.base,
|
||||||
curve: NooMotion.ease,
|
curve: NooMotion.ease,
|
||||||
top: _kAndroidPillTop,
|
top: pillTop,
|
||||||
left:
|
left:
|
||||||
itemWidth * selectedIndex +
|
itemWidth * selectedIndex +
|
||||||
(itemWidth - _kAndroidPillWidth) / 2,
|
(itemWidth - _kAndroidPillWidth) / 2,
|
||||||
@@ -105,9 +226,19 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
child: _AndroidItem(
|
child: _AndroidItem(
|
||||||
destination: destinations[i],
|
destination: destinations[i],
|
||||||
selected: i == selectedIndex,
|
selected: i == selectedIndex,
|
||||||
|
floating: floating,
|
||||||
onTap: () => onSelected(i),
|
onTap: () => onSelected(i),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (trailingSearch != null)
|
||||||
|
Expanded(
|
||||||
|
child: _AndroidItem(
|
||||||
|
destination: trailingSearch,
|
||||||
|
selected: false,
|
||||||
|
floating: floating,
|
||||||
|
onTap: onSearchTap!,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -117,6 +248,46 @@ class NooBottomBar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The floating bar's own Search entry point, next to the pill rather than
|
||||||
|
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
|
||||||
|
/// same fill/border as the pill so the two still read as one family.
|
||||||
|
class _SearchSatellite extends StatelessWidget {
|
||||||
|
final NooNavDestination destination;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const _SearchSatellite({
|
||||||
|
required this.destination,
|
||||||
|
required this.onTap,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
label: destination.label,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surface,
|
||||||
|
border: Border.all(color: colors.line),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: const [nooDialogShadow],
|
||||||
|
),
|
||||||
|
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _IosItem extends StatelessWidget {
|
class _IosItem extends StatelessWidget {
|
||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
@@ -165,24 +336,69 @@ class _IosItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
|
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
|
||||||
/// with its label always below it - the label's space is reserved
|
/// with its label below it. Attached always reserves the label's space
|
||||||
/// whether or not it's showing (only its opacity changes), so the icon
|
/// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on
|
||||||
/// never shifts vertically as selection changes, and stays aligned with
|
/// [NooBottomBar]) so the icon never shifts vertically as selection
|
||||||
/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight].
|
/// changes. Floating drops that reserved space when idle instead: there's
|
||||||
|
/// no label to leave room for, so the icon centers in the whole button and
|
||||||
|
/// renders a touch bigger.
|
||||||
class _AndroidItem extends StatelessWidget {
|
class _AndroidItem extends StatelessWidget {
|
||||||
final NooNavDestination destination;
|
final NooNavDestination destination;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final bool floating;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _AndroidItem({
|
const _AndroidItem({
|
||||||
required this.destination,
|
required this.destination,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
|
required this.floating,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
|
final iconColor = selected ? colors.accentText : colors.fg2;
|
||||||
|
|
||||||
|
final Widget content;
|
||||||
|
if (floating && !selected) {
|
||||||
|
content = Center(
|
||||||
|
child: Icon(destination.icon, size: 25, color: iconColor),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
top: floating ? _kFloatingPillTop : _kAttachedPillTop,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: _kAndroidPillHeight,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(destination.icon, size: 24, color: iconColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
AnimatedOpacity(
|
||||||
|
duration: NooMotion.base,
|
||||||
|
curve: NooMotion.ease,
|
||||||
|
opacity: selected ? 1 : 0,
|
||||||
|
child: Text(
|
||||||
|
destination.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.navLabelActive.copyWith(
|
||||||
|
height: 1,
|
||||||
|
color: colors.fg1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
selected: selected,
|
selected: selected,
|
||||||
@@ -190,39 +406,7 @@ class _AndroidItem extends StatelessWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Padding(
|
child: content,
|
||||||
padding: const EdgeInsets.only(top: _kAndroidPillTop),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
height: _kAndroidPillHeight,
|
|
||||||
child: Center(
|
|
||||||
child: Icon(
|
|
||||||
destination.icon,
|
|
||||||
size: 24,
|
|
||||||
color: selected ? colors.accentText : colors.fg2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
AnimatedOpacity(
|
|
||||||
duration: NooMotion.base,
|
|
||||||
curve: NooMotion.ease,
|
|
||||||
opacity: selected ? 1 : 0,
|
|
||||||
child: Text(
|
|
||||||
destination.label,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: NooText.navLabelActive.copyWith(
|
|
||||||
height: 1,
|
|
||||||
color: colors.fg1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,35 @@ enum NooNavStyle {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How [NooBottomBar] sits on the screen - user-configurable in Settings
|
||||||
|
/// (Appearance). [attached] is the original edge-to-edge bar with no
|
||||||
|
/// side/bottom margin. [floating] insets it 16px from both side edges and
|
||||||
|
/// clear of the bottom safe area, rounded (28px - the same radius the
|
||||||
|
/// sheet top/drawer edge use) rather than square, with a 1px `line` border
|
||||||
|
/// plus `nooDialogShadow` - product UI otherwise stays flat (DESIGN_SYSTEM.md
|
||||||
|
/// 1.4), but a bar that's genuinely floating above scrolling content reads
|
||||||
|
/// as attached without some elevation, so it borrows the one shadow the
|
||||||
|
/// rest of the app allows itself rather than inventing a second one. The
|
||||||
|
/// host `Scaffold` needs `extendBody: true` while floating, so its body
|
||||||
|
/// scrolls behind the bar's transparent margin instead of stopping short of
|
||||||
|
/// it like [attached] does - which also means that body needs its own
|
||||||
|
/// trailing padding to clear the bar (see `NooBottomBar.rowHeight`).
|
||||||
|
enum NooBottomBarStyle { attached, floating }
|
||||||
|
|
||||||
|
/// Which widget opens the shell's navigation menu (hidden tabs + Settings)
|
||||||
|
/// on mobile - user-configurable in Settings (Appearance), same precedent
|
||||||
|
/// as [NooBottomBarStyle]. [drawer] is the original pattern: a `menu`
|
||||||
|
/// icon, top-left of the top bar, opens a left-edge `Drawer`. [avatarMenu]
|
||||||
|
/// drops that icon entirely and repurposes the avatar button - already
|
||||||
|
/// sitting top-right, already a tap target every user already knows - as
|
||||||
|
/// the one entry point instead, opening a dropdown anchored below it (see
|
||||||
|
/// `showAvatarMenu` in `widgets/avatar_menu.dart`) rather than a drawer
|
||||||
|
/// sliding from the opposite edge. Applies on both platforms, since
|
||||||
|
/// [NooTopBar]'s `onMenu`/avatar wiring is shared chrome, not
|
||||||
|
/// Android-specific - only [AppDrawer]'s left-`Drawer` convention is being
|
||||||
|
/// offered an alternative, not anything platform-only.
|
||||||
|
enum NooNavMenuStyle { drawer, avatarMenu }
|
||||||
|
|
||||||
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
|
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
|
||||||
/// maps its own tab model (e.g. `AppTab`) onto these.
|
/// maps its own tab model (e.g. `AppTab`) onto these.
|
||||||
@immutable
|
@immutable
|
||||||
|
|||||||
@@ -48,58 +48,68 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar);
|
Size get preferredSize => const Size.fromHeight(outerHeight);
|
||||||
|
|
||||||
|
static const _margin = EdgeInsets.fromLTRB(12, 8, 12, 4);
|
||||||
|
|
||||||
|
/// Bar height plus the margin that makes it float.
|
||||||
|
static const double outerHeight = NooSizes.toolbar + 12;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
|
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
|
||||||
|
|
||||||
return Material(
|
return Padding(
|
||||||
color: backgroundColor ?? colors.surface,
|
padding: _margin,
|
||||||
child: Container(
|
child: Material(
|
||||||
height: NooSizes.toolbar,
|
color: backgroundColor ?? colors.surface,
|
||||||
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
shape: RoundedRectangleBorder(
|
||||||
border: Border(bottom: BorderSide(color: colors.line)),
|
borderRadius: BorderRadius.circular(NooRadii.card),
|
||||||
|
side: BorderSide(color: colors.line),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Container(
|
||||||
spacing: 12,
|
height: NooSizes.toolbar,
|
||||||
children: [
|
padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
|
||||||
if (showHistoryNav)
|
child: Row(
|
||||||
Padding(
|
spacing: 12,
|
||||||
padding: const EdgeInsetsDirectional.only(end: 4),
|
children: [
|
||||||
child: Row(
|
if (showHistoryNav)
|
||||||
mainAxisSize: MainAxisSize.min,
|
Padding(
|
||||||
spacing: 4,
|
padding: const EdgeInsetsDirectional.only(end: 4),
|
||||||
children: [
|
child: Row(
|
||||||
_HistoryButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: LucideIcons.chevronLeft,
|
spacing: 4,
|
||||||
tooltip: 'Back',
|
children: [
|
||||||
onTap: onBack,
|
_HistoryButton(
|
||||||
),
|
icon: LucideIcons.chevronLeft,
|
||||||
_HistoryButton(
|
tooltip: 'Back',
|
||||||
icon: LucideIcons.chevronRight,
|
onTap: onBack,
|
||||||
tooltip: 'Forward',
|
),
|
||||||
onTap: onForward,
|
_HistoryButton(
|
||||||
),
|
icon: LucideIcons.chevronRight,
|
||||||
],
|
tooltip: 'Forward',
|
||||||
|
onTap: onForward,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: AlignmentDirectional.centerStart,
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: titleStyle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
child: titleWidget ?? Text(title ?? ''),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
if (search != null) SizedBox(width: 260, child: search),
|
||||||
child: Align(
|
...actions,
|
||||||
alignment: AlignmentDirectional.centerStart,
|
],
|
||||||
child: DefaultTextStyle(
|
),
|
||||||
style: titleStyle,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
child: titleWidget ?? Text(title ?? ''),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (search != null) SizedBox(width: 260, child: search),
|
|
||||||
...actions,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
/// iOS only: the search field shown below the large title.
|
/// iOS only: the search field shown below the large title.
|
||||||
final Widget? search;
|
final Widget? search;
|
||||||
|
|
||||||
|
/// Android only: an extra widget (e.g. a search-field-styled launcher) in
|
||||||
|
/// the flexible middle slot, to the title's own right - [title] keeps
|
||||||
|
/// its natural (shrink-to-fit, ellipsized) width on the left, and this
|
||||||
|
/// takes whatever space is left rather than replacing it. iOS already has
|
||||||
|
/// a dedicated [search] slot below its large title for this; Android has
|
||||||
|
/// no large title to put a second row under, so a caller that wants an
|
||||||
|
/// inline search bar there puts it beside the title instead of adding a
|
||||||
|
/// row.
|
||||||
|
final Widget? androidTitleTrailing;
|
||||||
|
|
||||||
/// Defaults to `bg` - the top bar sits on the screen background.
|
/// Defaults to `bg` - the top bar sits on the screen background.
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
|
||||||
@@ -67,6 +77,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
this.onMenu,
|
this.onMenu,
|
||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.search,
|
this.search,
|
||||||
|
this.androidTitleTrailing,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +109,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
|
|
||||||
final Widget body = _ios
|
final Widget body = _ios
|
||||||
? _buildIos(colors, lead)
|
? _buildIos(colors, lead)
|
||||||
: _buildAndroid(colors, lead);
|
: _buildAndroid(context, colors, lead);
|
||||||
|
|
||||||
return _NooTopBarScope(
|
return _NooTopBarScope(
|
||||||
style: style,
|
style: style,
|
||||||
@@ -166,7 +177,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAndroid(NooColors colors, Widget? lead) {
|
Widget _buildAndroid(BuildContext context, NooColors colors, Widget? lead) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: _androidRow,
|
height: _androidRow,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -178,14 +189,34 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
spacing: 4,
|
spacing: 4,
|
||||||
children: [
|
children: [
|
||||||
?lead,
|
?lead,
|
||||||
Expanded(
|
// The title is a non-flex child capped at 40% of the screen
|
||||||
child: Text(
|
// width (so it keeps its natural width but still ellipsizes),
|
||||||
title,
|
// leaving the `Expanded` trailing widget all the remaining
|
||||||
maxLines: 1,
|
// space. A `Flexible` title would instead split the free space
|
||||||
overflow: TextOverflow.ellipsis,
|
// 1:1 with it, since both default to flex 1.
|
||||||
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
if (androidTitleTrailing != null) ...[
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.sizeOf(context).width * 0.4,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: NooSpace.md),
|
||||||
|
Expanded(child: androidTitleTrailing!),
|
||||||
|
] else
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: NooText.title.copyWith(height: 1, color: colors.fg1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
...actions,
|
...actions,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ class NooLayout {
|
|||||||
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
/// (table rows, 5-column grids, 8-column photos, 24px gutter).
|
||||||
static const double desktopBreakpoint = 900;
|
static const double desktopBreakpoint = 900;
|
||||||
|
|
||||||
static bool isDesktop(BuildContext context) =>
|
/// Shortest window side that counts as a tablet-class window (the usual
|
||||||
MediaQuery.sizeOf(context).width >= desktopBreakpoint;
|
/// 600dp rule). A phone in landscape is wide but short, so it stays on the
|
||||||
|
/// mobile layout.
|
||||||
|
static const double tabletShortestSide = 600;
|
||||||
|
|
||||||
|
static bool isDesktop(BuildContext context) {
|
||||||
|
final size = MediaQuery.sizeOf(context);
|
||||||
|
return size.width >= desktopBreakpoint &&
|
||||||
|
size.shortestSide >= tabletShortestSide;
|
||||||
|
}
|
||||||
|
|
||||||
static NooNavStyle navStyle(BuildContext context) =>
|
static NooNavStyle navStyle(BuildContext context) =>
|
||||||
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
NooNavStyle.fromPlatform(Theme.of(context).platform);
|
||||||
@@ -24,4 +32,16 @@ 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]
|
||||||
|
/// (so phones keep their usual count while wide non-sidebar windows, e.g. a
|
||||||
|
/// tablet in portrait, get more instead of a few huge tiles).
|
||||||
|
static int gridColumns(
|
||||||
|
BuildContext context, {
|
||||||
|
required int phone,
|
||||||
|
required double minTile,
|
||||||
|
}) {
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
return (width / minTile).floor().clamp(phone, 12);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ class NooShareSection extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: NooText.sectionTitle.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
?trailing,
|
?trailing,
|
||||||
],
|
],
|
||||||
@@ -60,11 +63,16 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
final String name;
|
final String name;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final bool owner;
|
final bool owner;
|
||||||
|
|
||||||
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
|
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
|
||||||
final String permission;
|
final String permission;
|
||||||
final VoidCallback? onPermissionTap;
|
final VoidCallback? onPermissionTap;
|
||||||
|
final bool permissionLoading;
|
||||||
final String ownerLabel;
|
final String ownerLabel;
|
||||||
|
|
||||||
|
/// Replaces the owner label / permission pill (e.g. a plain search result).
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
const NooPersonAccessRow({
|
const NooPersonAccessRow({
|
||||||
super.key,
|
super.key,
|
||||||
required this.avatar,
|
required this.avatar,
|
||||||
@@ -73,7 +81,9 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
this.owner = false,
|
this.owner = false,
|
||||||
this.permission = 'Can edit',
|
this.permission = 'Can edit',
|
||||||
this.onPermissionTap,
|
this.onPermissionTap,
|
||||||
|
this.permissionLoading = false,
|
||||||
this.ownerLabel = 'Owner',
|
this.ownerLabel = 'Owner',
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -113,10 +123,16 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: NooSpace.sm),
|
const SizedBox(width: NooSpace.sm),
|
||||||
if (owner)
|
if (trailing != null)
|
||||||
|
trailing!
|
||||||
|
else if (owner)
|
||||||
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
||||||
else
|
else
|
||||||
NooPermissionPill(label: permission, onTap: onPermissionTap),
|
NooPermissionPill(
|
||||||
|
label: permission,
|
||||||
|
onTap: onPermissionTap,
|
||||||
|
loading: permissionLoading,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -128,8 +144,14 @@ class NooPersonAccessRow extends StatelessWidget {
|
|||||||
class NooPermissionPill extends StatelessWidget {
|
class NooPermissionPill extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool loading;
|
||||||
|
|
||||||
const NooPermissionPill({super.key, required this.label, this.onTap});
|
const NooPermissionPill({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
this.onTap,
|
||||||
|
this.loading = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -147,9 +169,21 @@ class NooPermissionPill extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)),
|
Text(
|
||||||
|
label,
|
||||||
|
style: NooText.label.copyWith(height: 1, color: colors.fg1),
|
||||||
|
),
|
||||||
const SizedBox(width: NooSpace.xxs),
|
const SizedBox(width: NooSpace.xxs),
|
||||||
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
|
loading
|
||||||
|
? SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: colors.fg2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ class _NooSheetBody extends StatefulWidget {
|
|||||||
class _NooSheetBodyState extends State<_NooSheetBody> {
|
class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||||
bool _needsPeek = false;
|
bool _needsPeek = false;
|
||||||
|
|
||||||
|
// Keeps the sheet's content (and its State) alive when the body swaps
|
||||||
|
// between the plain and draggable layouts, instead of rebuilding it.
|
||||||
|
final _contentKey = GlobalKey();
|
||||||
|
|
||||||
void _handleOverflow(ScrollMetrics metrics) {
|
void _handleOverflow(ScrollMetrics metrics) {
|
||||||
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
|
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
|
||||||
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
|
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
|
||||||
@@ -84,6 +88,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
|||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
key: _contentKey,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -106,40 +111,51 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
// A modal bottom sheet doesn't avoid the keyboard itself, so a focused
|
||||||
child: LayoutBuilder(
|
// field would be covered. Inset by the keyboard and, while it's up, let
|
||||||
builder: (context, constraints) {
|
// the content use all the space above it.
|
||||||
if (!_needsPeek) {
|
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
return ConstrainedBox(
|
final keyboardUp = keyboard > 0;
|
||||||
constraints: BoxConstraints(
|
return Padding(
|
||||||
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction,
|
padding: EdgeInsets.only(bottom: keyboard),
|
||||||
),
|
child: SafeArea(
|
||||||
child: _buildScrollable(null),
|
bottom: !keyboardUp,
|
||||||
);
|
child: LayoutBuilder(
|
||||||
}
|
builder: (context, constraints) {
|
||||||
return DraggableScrollableSheet(
|
if (!_needsPeek) {
|
||||||
initialChildSize: _kNooSheetPeekFraction,
|
return ConstrainedBox(
|
||||||
minChildSize: _kNooSheetMinFraction,
|
constraints: BoxConstraints(
|
||||||
maxChildSize: _kNooSheetMaxFraction,
|
maxHeight: keyboardUp
|
||||||
expand: false,
|
? constraints.maxHeight
|
||||||
builder: (context, scrollController) {
|
: constraints.maxHeight * _kNooSheetPeekFraction,
|
||||||
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
|
),
|
||||||
// route on its own when dragged down to its floor - it just
|
child: _buildScrollable(null),
|
||||||
// stops resizing there - so pop explicitly once it's been
|
|
||||||
// dragged (near) all the way down, matching a native
|
|
||||||
// peek sheet's swipe-to-dismiss.
|
|
||||||
return NotificationListener<DraggableScrollableNotification>(
|
|
||||||
onNotification: (notification) {
|
|
||||||
if (notification.extent <= notification.minExtent + 0.01) {
|
|
||||||
Navigator.of(context).maybePop();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
child: _buildScrollable(scrollController),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
return DraggableScrollableSheet(
|
||||||
},
|
initialChildSize: _kNooSheetPeekFraction,
|
||||||
|
minChildSize: _kNooSheetMinFraction,
|
||||||
|
maxChildSize: _kNooSheetMaxFraction,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
|
||||||
|
// route on its own when dragged down to its floor - it just
|
||||||
|
// stops resizing there - so pop explicitly once it's been
|
||||||
|
// dragged (near) all the way down, matching a native
|
||||||
|
// peek sheet's swipe-to-dismiss.
|
||||||
|
return NotificationListener<DraggableScrollableNotification>(
|
||||||
|
onNotification: (notification) {
|
||||||
|
if (notification.extent <= notification.minExtent + 0.01) {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: _buildScrollable(scrollController),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart';
|
|||||||
import '../noo/core/noo_segmented_control.dart';
|
import '../noo/core/noo_segmented_control.dart';
|
||||||
import '../noo/core/noo_toggle.dart';
|
import '../noo/core/noo_toggle.dart';
|
||||||
import '../noo/lists/noo_settings_row.dart';
|
import '../noo/lists/noo_settings_row.dart';
|
||||||
|
import '../noo/nav/noo_nav_style.dart';
|
||||||
import '../noo/noo_layout.dart';
|
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';
|
||||||
@@ -47,7 +48,32 @@ class SettingsAppearanceSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: 'Appearance',
|
title: 'Appearance',
|
||||||
children: [_ThemeRow(settings: settings)],
|
notice:
|
||||||
|
'Bottom bar style, menu style and search in bottom bar have no effect on wide tablets, which use a sidebar.',
|
||||||
|
children: [
|
||||||
|
_ThemeRow(settings: settings),
|
||||||
|
_BottomBarStyleRow(settings: settings),
|
||||||
|
_NavMenuStyleRow(settings: settings),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
label: const Text('Search in bottom bar'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Adds Search to the bottom bar and removes it from the top '
|
||||||
|
'bar - leaves room for one fewer regular tab',
|
||||||
|
),
|
||||||
|
trailing: NooToggle(
|
||||||
|
checked: settings.searchInBottomBar,
|
||||||
|
onChanged: settings.setSearchInBottomBar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: NooSpace.xl),
|
||||||
|
SettingsSection(
|
||||||
|
title: 'Upload button',
|
||||||
|
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.',
|
||||||
|
children: [_FabStyleRow(settings: settings)],
|
||||||
),
|
),
|
||||||
const SizedBox(height: NooSpace.xl),
|
const SizedBox(height: NooSpace.xl),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
@@ -156,6 +182,182 @@ class _ThemeRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The "Bottom bar" row: same "label, then a full-width segmented control
|
||||||
|
/// below it" shape as [_ThemeRow] right above it. Attached is the original
|
||||||
|
/// edge-to-edge bar; floating insets it from both side/bottom edges with
|
||||||
|
/// rounded corners - see [NooBottomBarStyle]'s own doc comment for the
|
||||||
|
/// full visual rationale.
|
||||||
|
class _BottomBarStyleRow extends StatelessWidget {
|
||||||
|
final SettingsController settings;
|
||||||
|
|
||||||
|
const _BottomBarStyleRow({required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Material(
|
||||||
|
color: colors.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.panelBottom, size: 20, color: colors.fg2),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Text(
|
||||||
|
'Bottom bar',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
NooSegmentedControl<NooBottomBarStyle>(
|
||||||
|
fill: true,
|
||||||
|
value: settings.bottomBarStyle,
|
||||||
|
onChanged: settings.setBottomBarStyle,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: NooBottomBarStyle.attached,
|
||||||
|
icon: LucideIcons.panelBottom,
|
||||||
|
label: 'Attached',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: NooBottomBarStyle.floating,
|
||||||
|
icon: LucideIcons.panelBottomOpen,
|
||||||
|
label: 'Floating',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Navigation menu" (Settings → Appearance): which widget opens hidden
|
||||||
|
/// tabs + Settings - the original `menu` icon that opens a left `Drawer`,
|
||||||
|
/// or the avatar button instead (opens `showAvatarMenu`, a dropdown
|
||||||
|
/// anchored under it) - see [NooNavMenuStyle]'s own doc comment for the
|
||||||
|
/// full reachability rationale (dropping the hamburger removes one of the
|
||||||
|
/// two top-corner targets the shell asks a thumb to reach, rather than
|
||||||
|
/// adding a third kind of chrome like a side rail would).
|
||||||
|
class _NavMenuStyleRow extends StatelessWidget {
|
||||||
|
final SettingsController settings;
|
||||||
|
|
||||||
|
const _NavMenuStyleRow({required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Material(
|
||||||
|
color: colors.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.panelLeft, size: 20, color: colors.fg2),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Text(
|
||||||
|
'Navigation menu',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
NooSegmentedControl<NooNavMenuStyle>(
|
||||||
|
fill: true,
|
||||||
|
value: settings.navMenuStyle,
|
||||||
|
onChanged: settings.setNavMenuStyle,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: NooNavMenuStyle.drawer,
|
||||||
|
icon: LucideIcons.menu,
|
||||||
|
label: 'Hamburger',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: NooNavMenuStyle.avatarMenu,
|
||||||
|
icon: LucideIcons.userRound,
|
||||||
|
label: 'Avatar',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FabStyleRow extends StatelessWidget {
|
||||||
|
final SettingsController settings;
|
||||||
|
|
||||||
|
const _FabStyleRow({required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
return Material(
|
||||||
|
color: colors.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: NooSpace.md,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.circlePlus, size: 20, color: colors.fg2),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Text(
|
||||||
|
'Upload button size',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
NooSegmentedControl<FabStyle>(
|
||||||
|
fill: true,
|
||||||
|
value: settings.fabStyle,
|
||||||
|
onChanged: settings.setFabStyle,
|
||||||
|
options: const [
|
||||||
|
NooSegmentOption(
|
||||||
|
value: FabStyle.auto,
|
||||||
|
icon: LucideIcons.wandSparkles,
|
||||||
|
label: 'Auto',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: FabStyle.mini,
|
||||||
|
icon: LucideIcons.minimize2,
|
||||||
|
label: 'Mini',
|
||||||
|
),
|
||||||
|
NooSegmentOption(
|
||||||
|
value: FabStyle.expanded,
|
||||||
|
icon: LucideIcons.maximize2,
|
||||||
|
label: 'Expanded',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The current accent-color choice, shown as a small filled circle in the
|
/// The current accent-color choice, shown as a small filled circle in the
|
||||||
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
||||||
/// color instead of a fixed swatch.
|
/// color instead of a fixed swatch.
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/lists/noo_grouped_list.dart';
|
import '../noo/lists/noo_grouped_list.dart';
|
||||||
|
import '../noo/lists/noo_info_note.dart';
|
||||||
import '../noo/noo_layout.dart';
|
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';
|
||||||
|
|
||||||
/// One block of Settings (DESIGN_SYSTEM.md 4's 8-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
|
||||||
/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid
|
/// ("Mobile uses one column of grouped lists. Desktop uses a 2-column grid
|
||||||
@@ -17,10 +18,14 @@ class SettingsSection extends StatelessWidget {
|
|||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
|
||||||
|
/// Shown as a prominent [NooInfoNote] above the rows.
|
||||||
|
final String? notice;
|
||||||
|
|
||||||
const SettingsSection({
|
const SettingsSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
|
this.notice,
|
||||||
required this.children,
|
required this.children,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,12 +35,8 @@ class SettingsSection extends StatelessWidget {
|
|||||||
if (!NooLayout.isDesktop(context)) {
|
if (!NooLayout.isDesktop(context)) {
|
||||||
return NooGroupedList(
|
return NooGroupedList(
|
||||||
label: title,
|
label: title,
|
||||||
|
notice: notice != null ? NooInfoNote(message: notice!) : null,
|
||||||
footer: subtitle != null ? Text(subtitle!) : null,
|
footer: subtitle != null ? Text(subtitle!) : null,
|
||||||
// Every mobile Settings section is individually collapsible,
|
|
||||||
// expanded by default - replaces the old trailing jump rail (see
|
|
||||||
// `account_view.dart`'s doc comment) as the way to navigate a long
|
|
||||||
// Settings screen quickly.
|
|
||||||
collapsible: true,
|
|
||||||
children: children,
|
children: children,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,6 +56,10 @@ class SettingsSection extends StatelessWidget {
|
|||||||
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),
|
||||||
|
if (notice != null) ...[
|
||||||
|
NooInfoNote(message: notice!),
|
||||||
|
const SizedBox(height: NooSpace.md),
|
||||||
|
],
|
||||||
FlatRowGroup(children: children),
|
FlatRowGroup(children: children),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ class SettingsSwipeSection extends StatelessWidget {
|
|||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
label: Text(_swipeActionLabel(action)),
|
label: Text(_swipeActionLabel(action)),
|
||||||
trailing: action == current
|
trailing: action == current
|
||||||
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText)
|
? Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 18,
|
||||||
|
color: context.nooColors.accentText,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
onChanged(action);
|
onChanged(action);
|
||||||
@@ -55,6 +59,7 @@ 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,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import '../noo/noo_layout.dart';
|
|||||||
import 'settings_section.dart';
|
import 'settings_section.dart';
|
||||||
|
|
||||||
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
|
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
|
||||||
/// to [maxVisibleTabs]) and default tab.
|
/// to [SettingsController.maxVisibleTabs] - one fewer while Appearance's
|
||||||
|
/// "Search in bottom bar" is on) and default tab.
|
||||||
class SettingsTabsSection extends StatelessWidget {
|
class SettingsTabsSection extends StatelessWidget {
|
||||||
const SettingsTabsSection({super.key});
|
const SettingsTabsSection({super.key});
|
||||||
|
|
||||||
|
|||||||
+185
-35
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
@@ -18,6 +19,7 @@ import 'noo/core/noo_avatar.dart';
|
|||||||
import 'noo/core/noo_button.dart';
|
import 'noo/core/noo_button.dart';
|
||||||
import 'noo/core/noo_chip.dart';
|
import 'noo/core/noo_chip.dart';
|
||||||
import 'noo/core/noo_toggle.dart';
|
import 'noo/core/noo_toggle.dart';
|
||||||
|
import 'noo/lists/noo_grouped_list.dart';
|
||||||
import 'noo/lists/noo_settings_row.dart';
|
import 'noo/lists/noo_settings_row.dart';
|
||||||
import 'noo/media/noo_photo_tile.dart';
|
import 'noo/media/noo_photo_tile.dart';
|
||||||
import 'noo/noo_layout.dart';
|
import 'noo/noo_layout.dart';
|
||||||
@@ -71,11 +73,13 @@ class ShareSheet extends StatefulWidget {
|
|||||||
return showNooSheet(
|
return showNooSheet(
|
||||||
context,
|
context,
|
||||||
children: [
|
children: [
|
||||||
NooOverlayHeader(
|
Builder(
|
||||||
leading: detailsFileTile(item),
|
builder: (sheetContext) => NooOverlayHeader(
|
||||||
title: item.name,
|
leading: detailsFileTile(item),
|
||||||
subtitle: detailsMetaLine(item),
|
title: item.name,
|
||||||
onClose: () => Navigator.pop(context),
|
subtitle: detailsMetaLine(item),
|
||||||
|
onClose: () => Navigator.of(sheetContext).pop(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
ShareSheet(item: item),
|
ShareSheet(item: item),
|
||||||
],
|
],
|
||||||
@@ -95,13 +99,42 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
bool _isSearching = false;
|
bool _isSearching = false;
|
||||||
bool _isAddingPerson = false;
|
bool _isAddingPerson = false;
|
||||||
bool _isTogglingLink = false;
|
bool _isTogglingLink = false;
|
||||||
|
String? _pendingShareeKey;
|
||||||
|
final Set<String> _updatingShareIds = {};
|
||||||
bool _isSharingFile = false;
|
bool _isSharingFile = false;
|
||||||
bool _showInherited = false;
|
bool _showInherited = false;
|
||||||
final _peopleController = TextEditingController();
|
final _peopleController = TextEditingController();
|
||||||
final _linkController = TextEditingController();
|
final _linkController = TextEditingController();
|
||||||
|
final _peopleFocus = FocusNode();
|
||||||
|
final _peopleKey = GlobalKey();
|
||||||
|
|
||||||
|
/// Once the user types, scrolls the sheet so the people field and its
|
||||||
|
/// results (under their title) sit near the top, leaving a little room under the sheet's
|
||||||
|
/// rounded top edge.
|
||||||
|
void _revealPeopleSection() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final ctx = _peopleKey.currentContext;
|
||||||
|
final box = ctx?.findRenderObject();
|
||||||
|
if (ctx == null || box is! RenderBox) return;
|
||||||
|
final position = Scrollable.maybeOf(ctx)?.position;
|
||||||
|
final viewport = RenderAbstractViewport.maybeOf(box);
|
||||||
|
if (position == null || viewport == null) return;
|
||||||
|
final target = (viewport.getOffsetToReveal(box, 0).offset - 8).clamp(
|
||||||
|
position.minScrollExtent,
|
||||||
|
position.maxScrollExtent,
|
||||||
|
);
|
||||||
|
position.animateTo(
|
||||||
|
target,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_peopleFocus.dispose();
|
||||||
_peopleController.dispose();
|
_peopleController.dispose();
|
||||||
_linkController.dispose();
|
_linkController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -128,6 +161,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _isSearching = true);
|
setState(() => _isSearching = true);
|
||||||
|
_revealPeopleSection();
|
||||||
final ops = context.read<ItemOperations>();
|
final ops = context.read<ItemOperations>();
|
||||||
final results = await ops.searchSharees(trimmed);
|
final results = await ops.searchSharees(trimmed);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -135,6 +169,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
_searchResults = results;
|
_searchResults = results;
|
||||||
_isSearching = false;
|
_isSearching = false;
|
||||||
});
|
});
|
||||||
|
_revealPeopleSection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The single "Name, email or group" field: reuses whichever match is
|
/// The single "Name, email or group" field: reuses whichever match is
|
||||||
@@ -151,15 +186,20 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _shareeKey(NextcloudSharee s) => '${s.shareTypeValue}:${s.shareWith}';
|
||||||
|
|
||||||
Future<void> _addSharee(NextcloudSharee sharee) async {
|
Future<void> _addSharee(NextcloudSharee sharee) async {
|
||||||
|
if (_pendingShareeKey != null) return;
|
||||||
final ops = context.read<ItemOperations>();
|
final ops = context.read<ItemOperations>();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
setState(() => _pendingShareeKey = _shareeKey(sharee));
|
||||||
final share = await ops.createShare(
|
final share = await ops.createShare(
|
||||||
path: widget.item.path,
|
path: widget.item.path,
|
||||||
shareType: sharee.shareTypeValue,
|
shareType: sharee.shareTypeValue,
|
||||||
shareWith: sharee.shareWith,
|
shareWith: sharee.shareWith,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
setState(() => _pendingShareeKey = null);
|
||||||
if (share == null) {
|
if (share == null) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -321,36 +361,93 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A small popup with the one action the compact [NooPersonAccessRow]
|
/// Permission menu for a person row: Can view / Can edit, then removing
|
||||||
/// permission pill has no room for - removing that person's access. There's
|
/// access. Keeps the reshare bit (16) as it was.
|
||||||
/// no permission-update endpoint on [ItemOperations] yet, so the pill
|
|
||||||
/// itself is read-only (see the rebuild report).
|
|
||||||
void _openPersonMenu(NextcloudShare share) {
|
void _openPersonMenu(NextcloudShare share) {
|
||||||
final label = share.sharedWithDisplayName ?? 'Shared';
|
final label = share.sharedWithDisplayName ?? 'Shared';
|
||||||
|
final canEdit = (share.permissions & 2) != 0;
|
||||||
|
|
||||||
void remove() {
|
void remove() {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_removeShare(share);
|
_removeShare(share);
|
||||||
}
|
}
|
||||||
|
|
||||||
final row = NooSettingsRow(
|
void choose(bool edit) {
|
||||||
icon: LucideIcons.userMinus,
|
Navigator.pop(context);
|
||||||
label: const Text('Remove access'),
|
_setPermission(share, edit);
|
||||||
danger: true,
|
}
|
||||||
onTap: remove,
|
|
||||||
|
Widget check(bool on) => Icon(
|
||||||
|
LucideIcons.check,
|
||||||
|
size: 18,
|
||||||
|
color: on ? context.nooColors.accentText : Colors.transparent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final rows = <Widget>[
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.eye,
|
||||||
|
label: const Text('Can view'),
|
||||||
|
trailing: check(!canEdit),
|
||||||
|
onTap: () => choose(false),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.pencil,
|
||||||
|
label: const Text('Can edit'),
|
||||||
|
trailing: check(canEdit),
|
||||||
|
onTap: () => choose(true),
|
||||||
|
),
|
||||||
|
NooSettingsRow(
|
||||||
|
icon: LucideIcons.userMinus,
|
||||||
|
label: const Text('Remove access'),
|
||||||
|
danger: true,
|
||||||
|
onTap: remove,
|
||||||
|
),
|
||||||
|
];
|
||||||
if (NooLayout.isDesktop(context)) {
|
if (NooLayout.isDesktop(context)) {
|
||||||
showNooDialog(context, title: label, children: [row]);
|
showNooDialog(
|
||||||
|
context,
|
||||||
|
title: label,
|
||||||
|
children: [Column(children: rows)],
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showNooSheet(
|
showNooSheet(
|
||||||
context,
|
context,
|
||||||
children: [
|
children: [
|
||||||
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
|
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
|
||||||
row,
|
NooGroupedList(children: rows),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _setPermission(NextcloudShare share, bool edit) async {
|
||||||
|
if (((share.permissions & 2) != 0) == edit) return;
|
||||||
|
final ops = context.read<ItemOperations>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final reshare = share.permissions & 16;
|
||||||
|
final base = edit ? (share.isFolder ? 15 : 3) : 1;
|
||||||
|
final permissions = base | reshare;
|
||||||
|
setState(() => _updatingShareIds.add(share.id));
|
||||||
|
final ok = await ops.updateSharePermissions(share, permissions);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _updatingShareIds.remove(share.id));
|
||||||
|
if (ok) {
|
||||||
|
setState(() {
|
||||||
|
_shares = [
|
||||||
|
for (final s in _shares)
|
||||||
|
s.id == share.id ? s.withPermissions(permissions) : s,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Could not change permission'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _peopleSection(
|
Widget _peopleSection(
|
||||||
NooColors colors,
|
NooColors colors,
|
||||||
SessionController session,
|
SessionController session,
|
||||||
@@ -364,6 +461,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
absorbing: _isAddingPerson,
|
absorbing: _isAddingPerson,
|
||||||
child: NooTextField(
|
child: NooTextField(
|
||||||
controller: _peopleController,
|
controller: _peopleController,
|
||||||
|
focusNode: _peopleFocus,
|
||||||
leadingIcon: LucideIcons.search,
|
leadingIcon: LucideIcons.search,
|
||||||
placeholder: 'Name, email or group',
|
placeholder: 'Name, email or group',
|
||||||
onChanged: _search,
|
onChanged: _search,
|
||||||
@@ -381,17 +479,40 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final sharee in _searchResults)
|
for (final sharee in _searchResults)
|
||||||
NooSettingsRow(
|
InkWell(
|
||||||
icon: sharee.type == ShareeType.group
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||||
? LucideIcons.users
|
onTap: _pendingShareeKey == null ? () => _addSharee(sharee) : null,
|
||||||
: LucideIcons.user,
|
child: NooPersonAccessRow(
|
||||||
label: Text(sharee.label),
|
avatar: NooAvatar(
|
||||||
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
|
initials: _initial(sharee.label),
|
||||||
onTap: () => _addSharee(sharee),
|
color: NooPhotoTile.paletteColor(sharee.label.hashCode),
|
||||||
|
icon: sharee.type == ShareeType.group
|
||||||
|
? LucideIcons.users
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
name: sharee.label,
|
||||||
|
subtitle: sharee.subtitle,
|
||||||
|
trailing: _pendingShareeKey == _shareeKey(sharee)
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: NooSpace.xs),
|
if (_searchResults.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
|
||||||
|
child: Divider(height: 1, thickness: 1, color: colors.line),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(height: NooSpace.xs),
|
||||||
NooPersonAccessRow(
|
NooPersonAccessRow(
|
||||||
avatar: NooAvatar(initials: _initial(session.username), current: true),
|
avatar: NooAvatar(
|
||||||
|
initials: _initial(session.username),
|
||||||
|
current: true,
|
||||||
|
),
|
||||||
name: session.username,
|
name: session.username,
|
||||||
owner: true,
|
owner: true,
|
||||||
),
|
),
|
||||||
@@ -402,14 +523,21 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
color: NooPhotoTile.paletteColor(
|
color: NooPhotoTile.paletteColor(
|
||||||
(share.sharedWithDisplayName ?? share.id).hashCode,
|
(share.sharedWithDisplayName ?? share.id).hashCode,
|
||||||
),
|
),
|
||||||
icon: share.shareType == ShareType.group ? LucideIcons.users : null,
|
icon: share.shareType == ShareType.group
|
||||||
|
? LucideIcons.users
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
name: share.sharedWithDisplayName ?? 'Shared',
|
name: share.sharedWithDisplayName ?? 'Shared',
|
||||||
subtitle: share.shareType == ShareType.group
|
subtitle: share.shareType == ShareType.group
|
||||||
? 'Group'
|
? 'Group'
|
||||||
: (share.shareType == ShareType.email ? 'Invited by email' : null),
|
: (share.shareType == ShareType.email
|
||||||
|
? 'Invited by email'
|
||||||
|
: null),
|
||||||
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
|
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
|
||||||
onPermissionTap: () => _openPersonMenu(share),
|
permissionLoading: _updatingShareIds.contains(share.id),
|
||||||
|
onPermissionTap: _updatingShareIds.contains(share.id)
|
||||||
|
? null
|
||||||
|
: () => _openPersonMenu(share),
|
||||||
),
|
),
|
||||||
if (_inherited.isNotEmpty) ...[
|
if (_inherited.isNotEmpty) ...[
|
||||||
const SizedBox(height: NooSpace.xs),
|
const SizedBox(height: NooSpace.xs),
|
||||||
@@ -429,7 +557,9 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
Icon(
|
||||||
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
_showInherited
|
||||||
|
? LucideIcons.chevronUp
|
||||||
|
: LucideIcons.chevronDown,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: colors.fg3,
|
color: colors.fg3,
|
||||||
),
|
),
|
||||||
@@ -464,7 +594,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)),
|
Text(
|
||||||
|
'Internal link',
|
||||||
|
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
'For people who already have access',
|
'For people who already have access',
|
||||||
style: NooText.meta.copyWith(color: colors.fg3),
|
style: NooText.meta.copyWith(color: colors.fg3),
|
||||||
@@ -559,26 +692,43 @@ class _ShareSheetState extends State<ShareSheet> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
NooShareSection(
|
NooShareSection(
|
||||||
|
key: _peopleKey,
|
||||||
title: 'Share with people',
|
title: 'Share with people',
|
||||||
child: _peopleSection(colors, session, peopleShares, internalLink),
|
child: _peopleSection(colors, session, peopleShares, internalLink),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooShareSection(
|
NooShareSection(
|
||||||
title: 'Share link',
|
title: 'Share link',
|
||||||
trailing: NooToggle(
|
trailing: Row(
|
||||||
checked: hasLink,
|
mainAxisSize: MainAxisSize.min,
|
||||||
onChanged: _isTogglingLink ? null : _toggleLink,
|
children: [
|
||||||
|
if (_isTogglingLink) ...[
|
||||||
|
const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: NooSpace.sm),
|
||||||
|
],
|
||||||
|
NooToggle(
|
||||||
|
checked: hasLink,
|
||||||
|
onChanged: _isTogglingLink ? null : _toggleLink,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
caption: hasLink
|
caption: hasLink
|
||||||
? null
|
? null
|
||||||
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
|
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
|
||||||
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(),
|
child: hasLink
|
||||||
|
? _linkSection(publicLinkShares.first)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
if (!widget.item.isFolder) ...[
|
if (!widget.item.isFolder) ...[
|
||||||
const SizedBox(height: 22),
|
const SizedBox(height: 22),
|
||||||
NooShareSection(
|
NooShareSection(
|
||||||
title: 'Send file directly',
|
title: 'Send file directly',
|
||||||
caption: "Link settings above don't apply - this sends the file's bytes directly.",
|
caption:
|
||||||
|
"Link settings above don't apply - this sends the file's bytes directly.",
|
||||||
child: NooButton(
|
child: NooButton(
|
||||||
variant: NooButtonVariant.outline,
|
variant: NooButtonVariant.outline,
|
||||||
size: NooButtonSize.card,
|
size: NooButtonSize.card,
|
||||||
|
|||||||
@@ -212,13 +212,31 @@ class _AccountSwitchRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The current user's avatar in the mobile top bar. Tap opens the accounts
|
/// The current user's avatar in the mobile top bar. Tap opens the accounts
|
||||||
/// sheet ([showAccountSwitcher]); swiping up/down on it cycles to the
|
/// sheet ([showAccountSwitcher]) by default - or [onTap], when the caller
|
||||||
/// next/previous saved account immediately, with no confirmation - a
|
/// overrides it (`AppTopBar` does this for
|
||||||
/// shortcut alongside the full switcher.
|
/// `SettingsController.navMenuStyle`'s `avatarMenu` option, which
|
||||||
|
/// repurposes this same button to open `showAvatarMenu` instead; avoids
|
||||||
|
/// this file importing `widgets/avatar_menu.dart`, which itself imports
|
||||||
|
/// this file for `openSettings`/`accountInitial`/`serverHost`). Swiping
|
||||||
|
/// up/down on it cycles to the next/previous saved account immediately,
|
||||||
|
/// with no confirmation, regardless of [onTap] - a shortcut that stays
|
||||||
|
/// available either way.
|
||||||
class ShellAvatarButton extends StatelessWidget {
|
class ShellAvatarButton extends StatelessWidget {
|
||||||
final double hitBox;
|
final double hitBox;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const ShellAvatarButton({super.key, this.hitBox = 48});
|
/// Describes whatever [onTap] actually does - defaults to "Accounts"
|
||||||
|
/// (the account switcher), but a caller overriding [onTap] should
|
||||||
|
/// override this too so the tooltip/semantics describe the real action
|
||||||
|
/// instead of a stale default.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const ShellAvatarButton({
|
||||||
|
super.key,
|
||||||
|
this.hitBox = 48,
|
||||||
|
this.onTap,
|
||||||
|
this.label = 'Accounts',
|
||||||
|
});
|
||||||
|
|
||||||
Future<void> _handleVerticalSwipe(
|
Future<void> _handleVerticalSwipe(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
@@ -246,17 +264,17 @@ class ShellAvatarButton extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final session = context.watch<SessionController>();
|
final session = context.watch<SessionController>();
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: 'Accounts',
|
message: label,
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
label: 'Accounts',
|
label: label,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onVerticalDragEnd: (details) =>
|
onVerticalDragEnd: (details) =>
|
||||||
_handleVerticalSwipe(context, details),
|
_handleVerticalSwipe(context, details),
|
||||||
child: SizedBox.square(
|
child: SizedBox.square(
|
||||||
dimension: hitBox,
|
dimension: hitBox,
|
||||||
child: InkResponse(
|
child: InkResponse(
|
||||||
onTap: () => showAccountSwitcher(context),
|
onTap: onTap ?? () => showAccountSwitcher(context),
|
||||||
radius: hitBox / 2,
|
radius: hitBox / 2,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: NooAvatar(
|
child: NooAvatar(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/settings_controller.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
import '../noo/core/noo_button.dart';
|
import '../noo/core/noo_button.dart';
|
||||||
|
import '../noo/nav/noo_bottom_bar.dart';
|
||||||
|
import '../noo/noo_layout.dart';
|
||||||
|
|
||||||
/// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and
|
/// Shared loading/error/empty slivers for Recent/Activity/Trash/Shares (and
|
||||||
/// any future tab shaped like them: a `CustomScrollView` over one flat
|
/// any future tab shaped like them: a `CustomScrollView` over one flat
|
||||||
@@ -88,9 +92,72 @@ Widget tabEmptySliver(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much bottom padding a tab's scrollable list needs to clear
|
||||||
|
/// [NooBottomBar] and not just butt up against it. [NooBottomBarStyle.
|
||||||
|
/// attached] bars aren't drawn behind (no `Scaffold.extendBody`), so
|
||||||
|
/// Scaffold already shrinks the body above them - this is then pure
|
||||||
|
/// breathing room, not overlap prevention. [NooBottomBarStyle.floating]
|
||||||
|
/// bars draw over an extended body instead, so nothing reserves space for
|
||||||
|
/// them automatically: the clearance has to cover the bar's own footprint
|
||||||
|
/// (see [NooBottomBar.rowHeight]/[NooBottomBar.floatingBottomMargin]) plus
|
||||||
|
/// the safe-area inset below it, or the last row ends up hidden under it.
|
||||||
|
double bottomBarClearance(BuildContext context) {
|
||||||
|
final settings = context.watch<SettingsController>();
|
||||||
|
if (settings.bottomBarStyle == NooBottomBarStyle.attached) return 100;
|
||||||
|
final barHeight = NooBottomBar.rowHeight(
|
||||||
|
NooLayout.navStyle(context),
|
||||||
|
NooBottomBarStyle.floating,
|
||||||
|
);
|
||||||
|
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||||
|
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
||||||
|
}
|
||||||
|
|
||||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||||
/// same trailing pair every rebuilt tab list ends on.
|
/// same trailing pair every rebuilt tab list ends on.
|
||||||
const List<Widget> tabBottomInsetSlivers = [
|
List<Widget> tabBottomInsetSlivers(BuildContext context) => [
|
||||||
SliverToBoxAdapter(child: SizedBox(height: 100)),
|
SliverToBoxAdapter(child: SizedBox(height: bottomBarClearance(context))),
|
||||||
SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Wraps a tab's shell top bar ([AppTopBar], passed in as the generic
|
||||||
|
/// [PreferredSizeWidget] it implements - this file can't import
|
||||||
|
/// `app_top_bar.dart` without a cycle) as that tab's own first sliver,
|
||||||
|
/// living inside its `CustomScrollView` instead of `Scaffold.appBar`. Gives
|
||||||
|
/// it Material's native "floating app bar" behavior, via the framework's own
|
||||||
|
/// [SliverFloatingHeader]: it scrolls away as the list scrolls down, and -
|
||||||
|
/// unlike a plain `SliverToBoxAdapter`, which only reappears once scrolled
|
||||||
|
/// all the way back to the top - reappears immediately on any upward
|
||||||
|
/// scroll, following the finger while dragging and settling fully open or
|
||||||
|
/// fully closed once the gesture ends.
|
||||||
|
///
|
||||||
|
/// [SliverFloatingHeader] sizes itself from [topBar]'s own natural layout
|
||||||
|
/// (like `SliverToBoxAdapter`) rather than a fixed extent declared up
|
||||||
|
/// front - so [topBar]'s own internal `SafeArea` (see `NooTopBar`'s doc
|
||||||
|
/// comment) already accounts for the status-bar inset correctly while
|
||||||
|
/// [topBar] itself is visible, with no extra height math needed here
|
||||||
|
/// (unlike building this on the general-purpose `SliverPersistentHeader`
|
||||||
|
/// would have required).
|
||||||
|
///
|
||||||
|
/// Sits above a tab's own pinned in-content header (built with
|
||||||
|
/// [StickyHeaderDelegate] - the sort/filter controls row, or the selection
|
||||||
|
/// bar that replaces it) - put this sliver first in `contentSlivers` so
|
||||||
|
/// that header stays exactly where it already is, independent of whether
|
||||||
|
/// [topBar] is currently shown or scrolled away.
|
||||||
|
///
|
||||||
|
/// That pinned header needs its OWN protection from the status bar too,
|
||||||
|
/// though: [topBar]'s `SafeArea` only reserves space while [topBar] has
|
||||||
|
/// some height to put it in - once it's fully collapsed (0 height, [topBar]
|
||||||
|
/// scrolled all the way away), that reservation disappears with it, and
|
||||||
|
/// the pinned header would ride up underneath the status bar instead of
|
||||||
|
/// stopping below it (a real bug this shipped with once already - caught
|
||||||
|
/// by `tab_state_slivers_test.dart`'s regression test for it). Every tab
|
||||||
|
/// view wraps its whole `CustomScrollView` (this sliver, the pinned header,
|
||||||
|
/// and everything else) in `SafeArea(top: true, bottom: false, ...)` to
|
||||||
|
/// fix this - that reserves the inset outside the scrolling/collapsing
|
||||||
|
/// region entirely, so it's never implicated in this sliver's own
|
||||||
|
/// collapse math regardless of [topBar]'s current state. Flutter's
|
||||||
|
/// `SafeArea` nesting means this doesn't double the inset: the outer one
|
||||||
|
/// zeroes `MediaQuery.padding.top` for everything below it, so [topBar]'s
|
||||||
|
/// own inner `SafeArea` sees nothing left to add.
|
||||||
|
Widget topBarSliver(PreferredSizeWidget topBar) =>
|
||||||
|
SliverFloatingHeader(child: topBar);
|
||||||
|
|||||||
@@ -22,9 +22,14 @@ class MediaActionBar extends StatelessWidget {
|
|||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback onOpenExternally;
|
final VoidCallback onOpenExternally;
|
||||||
final VoidCallback onDownload;
|
final VoidCallback onDownload;
|
||||||
final VoidCallback onDetails;
|
final VoidCallback? onDetails;
|
||||||
|
final Widget? handle;
|
||||||
|
final Widget? below;
|
||||||
|
|
||||||
const MediaActionBar({
|
const MediaActionBar({
|
||||||
|
this.onDetails,
|
||||||
|
this.handle,
|
||||||
|
this.below,
|
||||||
super.key,
|
super.key,
|
||||||
required this.isFavorite,
|
required this.isFavorite,
|
||||||
required this.isBusy,
|
required this.isBusy,
|
||||||
@@ -35,7 +40,6 @@ class MediaActionBar extends StatelessWidget {
|
|||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
required this.onOpenExternally,
|
required this.onOpenExternally,
|
||||||
required this.onDownload,
|
required this.onDownload,
|
||||||
required this.onDetails,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,11 +52,15 @@ class MediaActionBar extends StatelessWidget {
|
|||||||
// short and leaving that strip unstyled - only the actual row content
|
// short and leaving that strip unstyled - only the actual row content
|
||||||
// needs padding up and away from the gesture area.
|
// needs padding up and away from the gesture area.
|
||||||
return FrostedGlassContainer(
|
return FrostedGlassContainer(
|
||||||
|
radius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(NooRadii.sheetTop),
|
||||||
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
?handle,
|
||||||
if (transportControls != null) ...[
|
if (transportControls != null) ...[
|
||||||
transportControls!,
|
transportControls!,
|
||||||
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
|
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
|
||||||
@@ -94,17 +102,18 @@ class MediaActionBar extends StatelessWidget {
|
|||||||
ViewerIconButton(
|
ViewerIconButton(
|
||||||
icon: LucideIcons.trash2,
|
icon: LucideIcons.trash2,
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
color: colors.danger,
|
|
||||||
onTap: isBusy ? null : onDelete,
|
onTap: isBusy ? null : onDelete,
|
||||||
),
|
),
|
||||||
ViewerIconButton(
|
if (onDetails != null)
|
||||||
icon: LucideIcons.info,
|
ViewerIconButton(
|
||||||
tooltip: 'Details',
|
icon: LucideIcons.info,
|
||||||
onTap: onDetails,
|
tooltip: 'Details',
|
||||||
),
|
onTap: onDetails,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
?below,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../models/nextcloud_item.dart';
|
||||||
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../details/details_sheet.dart';
|
||||||
|
import '../noo/overlays/noo_overlay_header.dart';
|
||||||
|
|
||||||
|
/// Builds the viewer's bottom bar with a drag [handle] on top and the
|
||||||
|
/// expandable [below] details region underneath its action row.
|
||||||
|
typedef MediaPanelBarBuilder =
|
||||||
|
Widget Function(BuildContext context, Widget handle, Widget below);
|
||||||
|
|
||||||
|
/// The media viewer's bottom bar as a draggable panel: dragging it (or
|
||||||
|
/// swiping up on the media, via [MediaDetailsPanelState.expand]) grows the
|
||||||
|
/// panel upward so the action row rides on top of the file's details instead
|
||||||
|
/// of the details covering it in a separate modal sheet.
|
||||||
|
class MediaDetailsPanel extends StatefulWidget {
|
||||||
|
final NextcloudItem item;
|
||||||
|
final MediaPanelBarBuilder builder;
|
||||||
|
|
||||||
|
const MediaDetailsPanel({
|
||||||
|
super.key,
|
||||||
|
required this.item,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MediaDetailsPanel> createState() => MediaDetailsPanelState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediaDetailsPanelState extends State<MediaDetailsPanel>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _c = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 260),
|
||||||
|
);
|
||||||
|
double _maxHeight = 1;
|
||||||
|
|
||||||
|
bool get isExpanded => _c.value > 0.5;
|
||||||
|
|
||||||
|
void expand() => _c.animateTo(1, curve: Curves.easeOutCubic);
|
||||||
|
void collapse() => _c.animateTo(0, curve: Curves.easeOutCubic);
|
||||||
|
void toggle() => isExpanded ? collapse() : expand();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_c.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragUpdate(DragUpdateDetails d) {
|
||||||
|
_c.value -= (d.primaryDelta ?? 0) / _maxHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDragEnd(DragEndDetails d) {
|
||||||
|
final v = d.primaryVelocity ?? 0;
|
||||||
|
if (v < -400) {
|
||||||
|
expand();
|
||||||
|
} else if (v > 400) {
|
||||||
|
collapse();
|
||||||
|
} else {
|
||||||
|
toggleToNearest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleToNearest() => _c.value > 0.5 ? expand() : collapse();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = context.nooColors;
|
||||||
|
_maxHeight = MediaQuery.sizeOf(context).height * 0.55;
|
||||||
|
|
||||||
|
final handle = Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 2),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.fg3.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final below = AnimatedBuilder(
|
||||||
|
animation: _c,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (_c.value == 0) return const SizedBox.shrink();
|
||||||
|
return SizedBox(
|
||||||
|
height: _c.value * _maxHeight,
|
||||||
|
child: ClipRect(
|
||||||
|
child: OverflowBox(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: _maxHeight,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
NooSpace.lg,
|
||||||
|
NooSpace.md,
|
||||||
|
NooSpace.lg,
|
||||||
|
NooSpace.lg,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
NooOverlayHeader(
|
||||||
|
leading: detailsFileTile(widget.item),
|
||||||
|
title: widget.item.name,
|
||||||
|
subtitle: detailsMetaLine(widget.item),
|
||||||
|
onClose: collapse,
|
||||||
|
),
|
||||||
|
const SizedBox(height: NooSpace.lg),
|
||||||
|
DetailsBody(
|
||||||
|
key: ValueKey(widget.item.id),
|
||||||
|
item: widget.item,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onVerticalDragUpdate: _onDragUpdate,
|
||||||
|
onVerticalDragEnd: _onDragEnd,
|
||||||
|
child: widget.builder(context, handle, below),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,8 +17,12 @@ 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).
|
||||||
|
final double topInset;
|
||||||
|
|
||||||
const MediaPdfPreview({
|
const MediaPdfPreview({
|
||||||
super.key,
|
super.key,
|
||||||
|
this.topInset = 0,
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.session,
|
required this.session,
|
||||||
this.localPathResolver,
|
this.localPathResolver,
|
||||||
@@ -29,8 +33,14 @@ class MediaPdfPreview extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||||
|
// Only the first few pages are measured; longer documents are assumed
|
||||||
|
// taller than any screen.
|
||||||
|
static const _sizedPages = 3;
|
||||||
|
|
||||||
PdfControllerPinch? _controller;
|
PdfControllerPinch? _controller;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
List<Size> _pageSizes = const [];
|
||||||
|
int _pageCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -55,9 +65,21 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
|||||||
await widget.session.service!.fetchBytes(widget.item.path),
|
await widget.session.service!.fetchBytes(widget.item.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
final document = await PdfDocument.openData(bytes);
|
||||||
|
final sizes = <Size>[];
|
||||||
|
for (var i = 1; i <= document.pagesCount && i <= _sizedPages; i++) {
|
||||||
|
// autoCloseAndroid closes the previous page itself; closing here too throws.
|
||||||
|
final page = await document.getPage(i, autoCloseAndroid: true);
|
||||||
|
sizes.add(Size(page.width, page.height));
|
||||||
|
}
|
||||||
|
if (!mounted) {
|
||||||
|
await document.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
|
_pageSizes = sizes;
|
||||||
|
_pageCount = document.pagesCount;
|
||||||
|
_controller = PdfControllerPinch(document: Future.value(document));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = e.toString());
|
if (mounted) setState(() => _error = e.toString());
|
||||||
@@ -91,6 +113,44 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
|||||||
// without ever pinching to zoom. There's no way to override that
|
// without ever pinching to zoom. There's no way to override that
|
||||||
// margin from here - it isn't an exposed parameter - so this avoids
|
// margin from here - it isn't an exposed parameter - so this avoids
|
||||||
// the branch that sets it instead of fighting it.
|
// the branch that sets it instead of fighting it.
|
||||||
return PdfViewPinch(controller: _controller!);
|
//
|
||||||
|
// pdfx's InteractiveViewer also refuses to zoom out past
|
||||||
|
// `viewport.height / document.height`. When the whole document is
|
||||||
|
// shorter than the screen (e.g. a one-page PDF) that ratio is above 1, so
|
||||||
|
// after pinching in, fit-width (scale 1) becomes unreachable. PdfViewPinch
|
||||||
|
// wraps its content in a SafeArea, which counts MediaQuery padding toward
|
||||||
|
// that document height, so padding the bottom up to the viewport height
|
||||||
|
// keeps the ratio at or below 1 without shrinking the gesture area.
|
||||||
|
return AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeInOutCubic,
|
||||||
|
padding: EdgeInsets.only(top: widget.topInset),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final media = MediaQuery.of(context);
|
||||||
|
var padding = media.padding.copyWith(top: 0);
|
||||||
|
if (_pageSizes.isNotEmpty && _pageSizes.length == _pageCount) {
|
||||||
|
const pagePadding = 10.0; // PdfViewPinch's default `padding`
|
||||||
|
final maxWidth = _pageSizes.fold<double>(
|
||||||
|
0,
|
||||||
|
(m, s) => s.width > m ? s.width : m,
|
||||||
|
);
|
||||||
|
final ratio = (constraints.maxWidth - pagePadding * 2) / maxWidth;
|
||||||
|
final docHeight = _pageSizes.fold<double>(
|
||||||
|
pagePadding,
|
||||||
|
(h, s) => h + s.height * ratio + pagePadding,
|
||||||
|
);
|
||||||
|
final needed = constraints.maxHeight - docHeight;
|
||||||
|
if (needed > padding.bottom) {
|
||||||
|
padding = padding.copyWith(bottom: needed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MediaQuery(
|
||||||
|
data: media.copyWith(padding: padding),
|
||||||
|
child: PdfViewPinch(controller: _controller!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
import '../../models/nextcloud_item.dart';
|
import '../../models/nextcloud_item.dart';
|
||||||
import '../../providers/session_controller.dart';
|
import '../../providers/session_controller.dart';
|
||||||
import '../../theme/design_tokens.dart';
|
import '../../theme/design_tokens.dart';
|
||||||
|
import '../noo/core/noo_button.dart';
|
||||||
|
|
||||||
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non-
|
/// Space the viewer's floating top bar and bottom action bar cover, so the
|
||||||
/// swipeable) preview path (`_textPreviewExtensions`).
|
/// text sits clear of them (and the status bar) instead of scrolling
|
||||||
|
/// underneath.
|
||||||
|
const double _kTopBarClearance = 72;
|
||||||
|
const double _kActionBarClearance = 112;
|
||||||
|
|
||||||
|
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
|
||||||
|
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
|
||||||
|
/// editable when online; a Save button appears once it has changed. Markdown
|
||||||
|
/// files open rendered, with an Edit/Preview toggle. Opened
|
||||||
|
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
|
||||||
|
/// local copy has no server to write back to.
|
||||||
class MediaTextPreview extends StatefulWidget {
|
class MediaTextPreview extends StatefulWidget {
|
||||||
final NextcloudItem item;
|
final NextcloudItem item;
|
||||||
final SessionController session;
|
final SessionController session;
|
||||||
@@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MediaTextPreviewState extends State<MediaTextPreview> {
|
class _MediaTextPreviewState extends State<MediaTextPreview> {
|
||||||
String? _content;
|
final _controller = TextEditingController();
|
||||||
|
String? _saved;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
bool _isSaving = false;
|
||||||
|
bool _editing = false;
|
||||||
|
|
||||||
|
bool get _isMarkdown {
|
||||||
|
final name = widget.item.name.toLowerCase();
|
||||||
|
return name.endsWith('.md') || name.endsWith('.markdown');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _showRendered => _isMarkdown && !_editing;
|
||||||
|
|
||||||
|
bool get _readOnly => widget.localPathResolver != null;
|
||||||
|
bool get _dirty => _saved != null && _controller.text != _saved;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_controller.addListener(() => setState(() {}));
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
try {
|
try {
|
||||||
final List<int> bytes;
|
final List<int> bytes;
|
||||||
@@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
bytes = await widget.session.service!.fetchBytes(widget.item.path);
|
bytes = await widget.session.service!.fetchBytes(widget.item.path);
|
||||||
}
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _content = utf8.decode(bytes, allowMalformed: true));
|
final text = utf8.decode(bytes, allowMalformed: true);
|
||||||
|
_controller.text = text;
|
||||||
|
setState(() => _saved = text);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _error = e.toString());
|
if (mounted) setState(() => _error = e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final text = _controller.text;
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
var ok = false;
|
||||||
|
try {
|
||||||
|
ok = await widget.session.service!.putBytes(
|
||||||
|
widget.item.path,
|
||||||
|
utf8.encode(text),
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSaving = false;
|
||||||
|
if (ok) _saved = text;
|
||||||
|
});
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = context.nooColors;
|
final colors = context.nooColors;
|
||||||
@@ -71,18 +130,103 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_content == null) {
|
if (_saved == null) {
|
||||||
return Center(child: CircularProgressIndicator(color: colors.accent));
|
return Center(child: CircularProgressIndicator(color: colors.accent));
|
||||||
}
|
}
|
||||||
|
final safe = MediaQuery.paddingOf(context);
|
||||||
|
final style = NooText.mono.copyWith(color: colors.fg1, height: 1.5);
|
||||||
|
final padding = EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
safe.top + _kTopBarClearance,
|
||||||
|
20,
|
||||||
|
safe.bottom + _kActionBarClearance,
|
||||||
|
);
|
||||||
return Container(
|
return Container(
|
||||||
color: colors.surface,
|
color: colors.surface,
|
||||||
padding: const EdgeInsets.all(20),
|
child: Stack(
|
||||||
child: SingleChildScrollView(
|
children: [
|
||||||
child: SelectableText(
|
Positioned.fill(
|
||||||
_content!,
|
child: _showRendered
|
||||||
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5),
|
? Markdown(
|
||||||
),
|
data: _controller.text,
|
||||||
|
padding: padding,
|
||||||
|
selectable: true,
|
||||||
|
styleSheet: _markdownStyle(context, colors),
|
||||||
|
onTapLink: (_, href, _) {},
|
||||||
|
)
|
||||||
|
: SingleChildScrollView(
|
||||||
|
padding: padding,
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
readOnly: _readOnly,
|
||||||
|
maxLines: null,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
style: style,
|
||||||
|
cursorColor: colors.accentText,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isMarkdown && !_readOnly)
|
||||||
|
Positioned(
|
||||||
|
right: 20,
|
||||||
|
top: safe.top + _kTopBarClearance - 8,
|
||||||
|
child: NooButton(
|
||||||
|
variant: NooButtonVariant.tonal,
|
||||||
|
size: NooButtonSize.compact,
|
||||||
|
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
|
||||||
|
onTap: () => setState(() => _editing = !_editing),
|
||||||
|
child: Text(_editing ? 'Preview' : 'Edit'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_dirty)
|
||||||
|
Positioned(
|
||||||
|
right: 20,
|
||||||
|
bottom: safe.bottom + _kActionBarClearance,
|
||||||
|
child: NooButton(
|
||||||
|
size: NooButtonSize.card,
|
||||||
|
icon: LucideIcons.save,
|
||||||
|
disabled: _isSaving,
|
||||||
|
onTap: _isSaving ? null : _save,
|
||||||
|
child: Text(_isSaving ? 'Saving…' : 'Save'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MarkdownStyleSheet _markdownStyle(BuildContext context, NooColors colors) {
|
||||||
|
final body = NooText.body.copyWith(color: colors.fg1, height: 1.55);
|
||||||
|
TextStyle heading(double size) =>
|
||||||
|
NooText.title.copyWith(color: colors.fg1, fontSize: size, height: 1.25);
|
||||||
|
return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||||
|
p: body,
|
||||||
|
h1: heading(26),
|
||||||
|
h2: heading(22),
|
||||||
|
h3: heading(19),
|
||||||
|
h4: heading(17),
|
||||||
|
listBullet: body,
|
||||||
|
a: body.copyWith(color: colors.accentText),
|
||||||
|
blockquote: body.copyWith(color: colors.fg2),
|
||||||
|
blockquoteDecoration: BoxDecoration(
|
||||||
|
border: Border(left: BorderSide(color: colors.line, width: 3)),
|
||||||
|
),
|
||||||
|
code: NooText.mono.copyWith(
|
||||||
|
color: colors.fg1,
|
||||||
|
backgroundColor: colors.surface2,
|
||||||
|
),
|
||||||
|
codeblockDecoration: BoxDecoration(
|
||||||
|
color: colors.surface2,
|
||||||
|
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||||
|
),
|
||||||
|
horizontalRuleDecoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: colors.line)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+16
-39
@@ -254,11 +254,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_driver:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -267,6 +262,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_markdown_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_markdown_plus
|
||||||
|
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.12"
|
||||||
flutter_plugin_android_lifecycle:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -333,11 +336,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
fuchsia_remote_debug_protocol:
|
|
||||||
dependency: transitive
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
google_fonts:
|
google_fonts:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -378,11 +376,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
integration_test:
|
|
||||||
dependency: "direct dev"
|
|
||||||
description: flutter
|
|
||||||
source: sdk
|
|
||||||
version: "0.0.0"
|
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -503,6 +496,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.20"
|
version: "3.1.20"
|
||||||
|
markdown:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: markdown
|
||||||
|
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.3.1"
|
||||||
marquee:
|
marquee:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -791,14 +792,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.8"
|
version: "2.1.8"
|
||||||
process:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: process
|
|
||||||
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.0.6"
|
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -932,14 +925,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
sync_http:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sync_http
|
|
||||||
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.3.1"
|
|
||||||
synchronized:
|
synchronized:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1084,14 +1069,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
webdriver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: webdriver
|
|
||||||
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.2.0"
|
|
||||||
webview_flutter:
|
webview_flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+4
-7
@@ -1,5 +1,5 @@
|
|||||||
name: noo
|
name: noo
|
||||||
description: "A new Flutter project."
|
description: "Noo - a fast, native Nextcloud client built with Flutter."
|
||||||
# The following line prevents the package from being accidentally published to
|
# The following line prevents the package from being accidentally published to
|
||||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# 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
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.0.2+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@@ -58,16 +58,12 @@ dependencies:
|
|||||||
webview_flutter: ^4.14.1
|
webview_flutter: ^4.14.1
|
||||||
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
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
# Drives tool/screenshots.sh (Play Store screenshots against a fake-data
|
|
||||||
# demo server) - dev only, never part of the release build.
|
|
||||||
integration_test:
|
|
||||||
sdk: flutter
|
|
||||||
|
|
||||||
# The "flutter_lints" package below contains a set of recommended lints to
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
# encourage good coding practices. The lint set provided by the package is
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
@@ -89,6 +85,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/icon/app_icon.png
|
- assets/icon/app_icon.png
|
||||||
- assets/icon/app_icon_monochrome.png
|
- assets/icon/app_icon_monochrome.png
|
||||||
|
- assets/google_fonts/
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
# assets:
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Imports open GitHub pull requests into Gitea as Agit PRs (refs/for/main).
|
||||||
|
# Gitea is the source of truth: review and merge there, and the push mirror
|
||||||
|
# updates GitHub. Run from cron, e.g. every 5 minutes:
|
||||||
|
# */5 * * * * /srv/noo-sync/scripts/sync-github-prs.sh >> /var/log/noo-sync.log 2>&1
|
||||||
|
#
|
||||||
|
# One PR: sync-github-prs.sh 42
|
||||||
|
# All: sync-github-prs.sh
|
||||||
|
#
|
||||||
|
# Needs: git, curl, jq, and a clone whose `origin` is Gitea (SSH deploy key
|
||||||
|
# or token with write access) plus env var GITHUB_REPO (e.g. ayushyamitabh/noo).
|
||||||
|
# GITHUB_TOKEN is optional (read-only, public repo) but avoids rate limits.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${GITHUB_REPO:?set GITHUB_REPO, e.g. ayushyamitabh/noo}"
|
||||||
|
STATE_DIR="${STATE_DIR:-$HOME/.noo-sync}"
|
||||||
|
BASE_BRANCH="${BASE_BRANCH:-main}"
|
||||||
|
mkdir -p "$STATE_DIR"
|
||||||
|
|
||||||
|
auth=()
|
||||||
|
[ -n "${GITHUB_TOKEN:-}" ] && auth=(-H "Authorization: Bearer $GITHUB_TOKEN")
|
||||||
|
|
||||||
|
git remote get-url github >/dev/null 2>&1 ||
|
||||||
|
git remote add github "https://github.com/$GITHUB_REPO.git"
|
||||||
|
|
||||||
|
import_pr() {
|
||||||
|
local n="$1" pr head_sha title url
|
||||||
|
pr=$(curl -fsS "${auth[@]}" "https://api.github.com/repos/$GITHUB_REPO/pulls/$n")
|
||||||
|
head_sha=$(jq -r .head.sha <<<"$pr")
|
||||||
|
title=$(jq -r '.title | gsub("[\r\n\t]+"; " ")' <<<"$pr")
|
||||||
|
url=$(jq -r .html_url <<<"$pr")
|
||||||
|
local stamp="$STATE_DIR/pr-$n"
|
||||||
|
if [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$head_sha" ]; then return; fi
|
||||||
|
|
||||||
|
git fetch --quiet origin "$BASE_BRANCH"
|
||||||
|
git fetch --quiet github "+refs/pull/$n/head"
|
||||||
|
# Agit: pushing to refs/for/<base> with a stable topic opens a Gitea PR the
|
||||||
|
# first time and updates the same PR on later pushes.
|
||||||
|
git push origin "FETCH_HEAD:refs/for/$BASE_BRANCH" \
|
||||||
|
-o "topic=gh-pr-$n" \
|
||||||
|
-o "title=[GitHub #$n] $title" \
|
||||||
|
-o "description=Imported from $url. Discuss on GitHub; merge here."
|
||||||
|
echo "$head_sha" >"$stamp"
|
||||||
|
echo "imported PR #$n @ $head_sha"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
for n in "$@"; do import_pr "$n"; done
|
||||||
|
else
|
||||||
|
curl -fsS "${auth[@]}" \
|
||||||
|
"https://api.github.com/repos/$GITHUB_REPO/pulls?state=open&per_page=50" |
|
||||||
|
jq -r '.[].number' | while read -r n; do import_pr "$n"; done
|
||||||
|
fi
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:noo/models/saved_account.dart';
|
||||||
|
import 'package:noo/providers/connectivity_controller.dart';
|
||||||
|
import 'package:noo/providers/files_controller.dart';
|
||||||
|
import 'package:noo/providers/session_controller.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
import 'package:noo/providers/sync_status_controller.dart';
|
||||||
|
import 'package:noo/theme/app_theme.dart';
|
||||||
|
import 'package:noo/views/account_view.dart';
|
||||||
|
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
|
||||||
|
/// Covers the mobile Settings navigation restructure: a top-level menu of
|
||||||
|
/// category rows (`account_view.dart`'s `_MobileMenu`) that pushes a
|
||||||
|
/// single-section screen per row, replacing the old design where every
|
||||||
|
/// section rendered inline in one long scrolling column. This only covers
|
||||||
|
/// the menu/push/back mechanics - each section's own content already has
|
||||||
|
/// (or doesn't need) its own coverage elsewhere.
|
||||||
|
void main() {
|
||||||
|
const secureStorageChannel = MethodChannel(
|
||||||
|
'plugins.it_nomads.com/flutter_secure_storage',
|
||||||
|
);
|
||||||
|
const connectivityChannel = MethodChannel(
|
||||||
|
'dev.fluttercommunity.plus/connectivity',
|
||||||
|
);
|
||||||
|
const connectivityStatusChannel = EventChannel(
|
||||||
|
'dev.fluttercommunity.plus/connectivity_status',
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountId = 'server_example_com__alice';
|
||||||
|
const passwordKey = 'nc_app_password_$accountId';
|
||||||
|
const password = 'app-password';
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(secureStorageChannel, (call) async {
|
||||||
|
if (call.method == 'readAll') {
|
||||||
|
return <String, String>{passwordKey: password};
|
||||||
|
}
|
||||||
|
if (call.method == 'read') {
|
||||||
|
final key = (call.arguments as Map)['key'] as String?;
|
||||||
|
return key == passwordKey ? password : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
// Offline, like `session_controller_test.dart`'s setup: keeps
|
||||||
|
// SessionController in a provisional login (no real HTTP calls) and
|
||||||
|
// stops FilesController/SyncStatusController from starting their
|
||||||
|
// network-fetch/periodic-refresh machinery, which would otherwise
|
||||||
|
// leave timers pending forever in a test.
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(connectivityChannel, (call) async {
|
||||||
|
if (call.method == 'check') return <String>['none'];
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockStreamHandler(
|
||||||
|
connectivityStatusChannel,
|
||||||
|
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpSettings(WidgetTester tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => SessionController(context.read()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => FilesController(context.read()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) =>
|
||||||
|
SyncStatusController(context.read(), context.read()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
||||||
|
home: const AccountView(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not `pumpAndSettle`: once the saved account is ready,
|
||||||
|
// FilesController/SyncStatusController start their own periodic
|
||||||
|
// refresh timers, which `pumpAndSettle` would spin on forever. A
|
||||||
|
// bounded number of small pumps is enough to flush the async gaps in
|
||||||
|
// SessionController's prefs restore and the dependent controllers'
|
||||||
|
// one-shot account-ready reactions (mirrors
|
||||||
|
// `session_controller_test.dart`'s `pumpUntil` loop).
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances exactly far enough to finish a push/pop transition
|
||||||
|
/// (`MaterialPageRoute`'s default is 300ms) without risking
|
||||||
|
/// `pumpAndSettle` picking up a pending periodic timer.
|
||||||
|
Future<void> settleNav(WidgetTester tester) async {
|
||||||
|
await tester.pump();
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryTitles = [
|
||||||
|
'Accounts',
|
||||||
|
'Security',
|
||||||
|
'File sync',
|
||||||
|
'Files cache',
|
||||||
|
'Appearance',
|
||||||
|
'Tabs',
|
||||||
|
'Action bar',
|
||||||
|
'Swipe on a file',
|
||||||
|
];
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'shows a top-level menu of category rows, not every section inline',
|
||||||
|
(tester) async {
|
||||||
|
await pumpSettings(tester);
|
||||||
|
|
||||||
|
// The account card (pinned, not a category row of its own) ...
|
||||||
|
expect(find.text('alice'), findsOneWidget);
|
||||||
|
// ... plus exactly one row per section.
|
||||||
|
for (final title in categoryTitles) {
|
||||||
|
expect(find.text(title), findsOneWidget);
|
||||||
|
}
|
||||||
|
// A section's own content isn't rendered until its row is tapped -
|
||||||
|
// this is a menu, not the old all-sections-inline column.
|
||||||
|
expect(
|
||||||
|
find.text("Require this device's PIN or biometric to open Noo"),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'tapping a category row pushes just that section, with a way back',
|
||||||
|
(tester) async {
|
||||||
|
await pumpSettings(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Security'));
|
||||||
|
await settleNav(tester);
|
||||||
|
|
||||||
|
// Landed on a separate, pushed Security screen: its own content -
|
||||||
|
// not shown anywhere on the top-level menu - is now visible. (The
|
||||||
|
// menu screen below it may stay mounted per `PageRoute.maintainState`,
|
||||||
|
// so this checks for the pushed screen's content rather than the
|
||||||
|
// menu's absence.)
|
||||||
|
expect(
|
||||||
|
find.text("Require this device's PIN or biometric to open Noo"),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// At least one `NooTopBarBack` now leads back - the pushed screen's
|
||||||
|
// own, on top of `AccountView`'s own (for returning to the shell).
|
||||||
|
expect(find.byType(NooTopBarBack), findsAtLeastNWidgets(1));
|
||||||
|
|
||||||
|
// Tapping the topmost back button pops the pushed screen back off,
|
||||||
|
// taking its content with it - unlike the underlying menu, a popped
|
||||||
|
// route is actually removed, so this absence check is meaningful.
|
||||||
|
await tester.tap(find.byType(NooTopBarBack).last);
|
||||||
|
await settleNav(tester);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.text("Require this device's PIN or biometric to open Noo"),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
expect(find.text('Security'), findsOneWidget);
|
||||||
|
expect(find.text('alice'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.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/app_tab.dart';
|
||||||
|
import 'package:noo/models/saved_account.dart';
|
||||||
|
import 'package:noo/providers/connectivity_controller.dart';
|
||||||
|
import 'package:noo/providers/files_controller.dart';
|
||||||
|
import 'package:noo/providers/session_controller.dart';
|
||||||
|
import 'package:noo/providers/settings_controller.dart';
|
||||||
|
import 'package:noo/providers/sync_status_controller.dart';
|
||||||
|
import 'package:noo/providers/trash_controller.dart';
|
||||||
|
import 'package:noo/theme/app_theme.dart';
|
||||||
|
import 'package:noo/widgets/app_top_bar.dart';
|
||||||
|
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
|
||||||
|
|
||||||
|
/// Covers `SettingsController.navMenuStyle`'s two options as wired through
|
||||||
|
/// `AppTopBar`/`ShellAvatarButton`: [NooNavMenuStyle.drawer] (today's
|
||||||
|
/// hamburger-opens-a-left-Drawer pattern, unchanged) vs
|
||||||
|
/// [NooNavMenuStyle.avatarMenu] (no hamburger at all - the avatar opens
|
||||||
|
/// `showAvatarMenu`'s dropdown instead of the account switcher).
|
||||||
|
void main() {
|
||||||
|
const secureStorageChannel = MethodChannel(
|
||||||
|
'plugins.it_nomads.com/flutter_secure_storage',
|
||||||
|
);
|
||||||
|
const connectivityChannel = MethodChannel(
|
||||||
|
'dev.fluttercommunity.plus/connectivity',
|
||||||
|
);
|
||||||
|
const connectivityStatusChannel = EventChannel(
|
||||||
|
'dev.fluttercommunity.plus/connectivity_status',
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountId = 'server_example_com__alice';
|
||||||
|
const passwordKey = 'nc_app_password_$accountId';
|
||||||
|
const password = 'app-password';
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
GoogleFonts.config.allowRuntimeFetching = false;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(secureStorageChannel, (call) async {
|
||||||
|
if (call.method == 'readAll') {
|
||||||
|
return <String, String>{passwordKey: password};
|
||||||
|
}
|
||||||
|
if (call.method == 'read') {
|
||||||
|
final key = (call.arguments as Map)['key'] as String?;
|
||||||
|
return key == passwordKey ? password : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
// Offline, same as `account_view_test.dart`'s setup: keeps
|
||||||
|
// SessionController in a provisional login and stops
|
||||||
|
// FilesController/SyncStatusController/TrashController's network/timer
|
||||||
|
// machinery from hanging the test.
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(connectivityChannel, (call) async {
|
||||||
|
if (call.method == 'check') return <String>['none'];
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockStreamHandler(
|
||||||
|
connectivityStatusChannel,
|
||||||
|
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> pumpTopBar(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required NooNavMenuStyle navMenuStyle,
|
||||||
|
}) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider(create: (_) => ConnectivityController()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => SessionController(context.read()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(create: (_) => SettingsController()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => FilesController(context.read()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) =>
|
||||||
|
SyncStatusController(context.read(), context.read()),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (context) => TrashController(context.read()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: AppTheme.light(AppTheme.defaultAccent, useDynamicColor: false),
|
||||||
|
home: Scaffold(
|
||||||
|
drawer: const Drawer(child: Text('drawer open')),
|
||||||
|
appBar: AppTopBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
tab: AppTab.files,
|
||||||
|
navMenuStyle: navMenuStyle,
|
||||||
|
),
|
||||||
|
body: const SizedBox(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not `pumpAndSettle` - the same reasoning as `account_view_test.dart`:
|
||||||
|
// the active account's dependent controllers start periodic
|
||||||
|
// timers/futures that would spin it forever.
|
||||||
|
for (var i = 0; i < 30; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('avatarMenu style: no hamburger, avatar opens the dropdown', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Menu'), findsOneWidget);
|
||||||
|
expect(find.byIcon(LucideIcons.menu), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Menu'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
// The dropdown's own header + Settings row - not the account
|
||||||
|
// switcher's "Add account"/"Manage accounts" rows.
|
||||||
|
expect(find.text('alice'), findsOneWidget);
|
||||||
|
expect(find.text('Settings'), findsOneWidget);
|
||||||
|
expect(find.text('Add account'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('drawer style (default): hamburger opens the real drawer', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Menu'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Menu'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('drawer open'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'drawer style (default): avatar still opens the account switcher',
|
||||||
|
(tester) async {
|
||||||
|
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.drawer);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Accounts'), findsOneWidget);
|
||||||
|
await tester.tap(find.byTooltip('Accounts'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.text('Add account'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import 'package:noo/widgets/noo/core/noo_progress_bar.dart';
|
|||||||
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
import 'package:noo/widgets/noo/core/noo_search_field.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
|
import 'package:noo/widgets/noo/core/noo_segmented_control.dart';
|
||||||
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
import 'package:noo/widgets/noo/core/noo_toggle.dart';
|
||||||
|
import 'package:noo/widgets/noo/nav/noo_nav_style.dart';
|
||||||
|
|
||||||
import 'noo_test_utils.dart';
|
import 'noo_test_utils.dart';
|
||||||
|
|
||||||
@@ -247,7 +248,11 @@ void main() {
|
|||||||
group('NooFab', () {
|
group('NooFab', () {
|
||||||
testNooWidgets('shows label and fires onTap', (tester, theme, c) async {
|
testNooWidgets('shows label and fires onTap', (tester, theme, c) async {
|
||||||
var taps = 0;
|
var taps = 0;
|
||||||
await pumpNoo(tester, NooFab(onTap: () => taps++), theme: theme);
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
NooFab(barStyle: NooBottomBarStyle.floating, onTap: () => taps++),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
expect(find.text('Upload'), findsOneWidget);
|
expect(find.text('Upload'), findsOneWidget);
|
||||||
expect(tester.getSize(find.byType(NooFab)).height, 56);
|
expect(tester.getSize(find.byType(NooFab)).height, 56);
|
||||||
expect(tester.getSize(find.byType(NooFab)).width, lessThan(200));
|
expect(tester.getSize(find.byType(NooFab)).width, lessThan(200));
|
||||||
@@ -263,7 +268,11 @@ void main() {
|
|||||||
var taps = 0;
|
var taps = 0;
|
||||||
await pumpNoo(
|
await pumpNoo(
|
||||||
tester,
|
tester,
|
||||||
NooFab(collapsed: true, onTap: () => taps++),
|
NooFab(
|
||||||
|
collapsed: true,
|
||||||
|
barStyle: NooBottomBarStyle.floating,
|
||||||
|
onTap: () => taps++,
|
||||||
|
),
|
||||||
theme: theme,
|
theme: theme,
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -274,6 +283,44 @@ void main() {
|
|||||||
await tester.tap(find.byType(NooFab));
|
await tester.tap(find.byType(NooFab));
|
||||||
expect(taps, 1);
|
expect(taps, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testNooWidgets('floating bar style stays fully round', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
const NooFab(barStyle: NooBottomBarStyle.floating),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
final material = tester.widget<Material>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(NooFab),
|
||||||
|
matching: find.byType(Material),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.pill);
|
||||||
|
});
|
||||||
|
|
||||||
|
testNooWidgets('attached bar style squares off to a rounded square', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
const NooFab(barStyle: NooBottomBarStyle.attached),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
final material = tester.widget<Material>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byType(NooFab),
|
||||||
|
matching: find.byType(Material),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect((material.borderRadius as BorderRadius).topLeft.x, NooRadii.card);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('NooProgressBar', () {
|
group('NooProgressBar', () {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/widgets/noo/files/noo_swipe_action.dart';
|
||||||
|
|
||||||
|
import 'noo_test_utils.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpNooTests();
|
||||||
|
|
||||||
|
group('NooSwipeAction', () {
|
||||||
|
testNooWidgets('a moderate swipe opens the action without triggering it', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var triggered = 0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 64,
|
||||||
|
child: NooSwipeAction(
|
||||||
|
endAction: NooSwipeActionSpec(
|
||||||
|
kind: NooSwipeActionKind.delete,
|
||||||
|
onTriggered: () => triggered++,
|
||||||
|
),
|
||||||
|
child: const ColoredBox(
|
||||||
|
color: Colors.white,
|
||||||
|
child: SizedBox(width: 360, height: 64),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
// Half the block's own width (96) is well past the "snap open"
|
||||||
|
// threshold but nowhere near the 1.8x trigger extent.
|
||||||
|
await tester.drag(find.byType(NooSwipeAction), const Offset(-60, 0));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(triggered, 0);
|
||||||
|
expect(find.text('Delete'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testNooWidgets(
|
||||||
|
'swiping past the trigger extent fires the action on release - no '
|
||||||
|
'second tap needed',
|
||||||
|
(tester, theme, c) async {
|
||||||
|
var triggered = 0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 64,
|
||||||
|
child: NooSwipeAction(
|
||||||
|
endAction: NooSwipeActionSpec(
|
||||||
|
kind: NooSwipeActionKind.delete,
|
||||||
|
onTriggered: () => triggered++,
|
||||||
|
),
|
||||||
|
child: const ColoredBox(
|
||||||
|
color: Colors.white,
|
||||||
|
child: SizedBox(width: 360, height: 64),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
// 300px of drag on a 96px-wide block clears the 1.8x (~173px)
|
||||||
|
// trigger extent well past the clamp ceiling.
|
||||||
|
await tester.drag(find.byType(NooSwipeAction), const Offset(-300, 0));
|
||||||
|
await tester.pump();
|
||||||
|
expect(triggered, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testNooWidgets('tapping the revealed block still triggers the action too', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var triggered = 0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 64,
|
||||||
|
child: NooSwipeAction(
|
||||||
|
startAction: NooSwipeActionSpec(
|
||||||
|
kind: NooSwipeActionKind.favorite,
|
||||||
|
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);
|
||||||
|
await tester.tap(find.text('Favorite'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(triggered, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -137,5 +137,205 @@ void main() {
|
|||||||
final labelCenter = tester.getCenter(find.text('Photos'));
|
final labelCenter = tester.getCenter(find.text('Photos'));
|
||||||
expect(labelCenter.dy, greaterThan(iconCenter.dy));
|
expect(labelCenter.dy, greaterThan(iconCenter.dy));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 96,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
barStyle: NooBottomBarStyle.floating,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
final pillFinder = find.descendant(
|
||||||
|
of: find.byType(NooBottomBar),
|
||||||
|
matching: find.byWidgetPredicate(
|
||||||
|
(w) => w is Container && w.decoration != null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final container = tester.widget<Container>(pillFinder);
|
||||||
|
final decoration = container.decoration as BoxDecoration;
|
||||||
|
expect(decoration.borderRadius, BorderRadius.circular(28));
|
||||||
|
expect(decoration.border, Border.all(color: c.line));
|
||||||
|
// Inset from both side edges of the bar's own host box, not flush
|
||||||
|
// against it - both rects are in the same (global) coordinate space,
|
||||||
|
// so comparing them directly still holds regardless of where
|
||||||
|
// `pumpNoo`'s own harness centers that host box on screen.
|
||||||
|
final hostRect = tester.getRect(find.byType(NooBottomBar));
|
||||||
|
final rect = tester.getRect(pillFinder);
|
||||||
|
expect(rect.left, greaterThan(hostRect.left));
|
||||||
|
expect(rect.right, lessThan(hostRect.right));
|
||||||
|
});
|
||||||
|
|
||||||
|
testNooWidgets(
|
||||||
|
'floating android: idle icon has no reserved label space and is '
|
||||||
|
'bigger than the active one',
|
||||||
|
(tester, theme, c) async {
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 96,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
barStyle: NooBottomBarStyle.floating,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
// Unlike attached, an idle floating tab has no label at all - not
|
||||||
|
// just an invisible one - so there's nothing to find here.
|
||||||
|
expect(find.text('Photos'), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byIcon(LucideIcons.images),
|
||||||
|
matching: find.byType(AnimatedOpacity),
|
||||||
|
),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
|
final activeIcon = tester.widget<Icon>(find.byIcon(LucideIcons.folder));
|
||||||
|
final idleIcon = tester.widget<Icon>(find.byIcon(LucideIcons.images));
|
||||||
|
expect(idleIcon.size, greaterThan(activeIcon.size!));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testNooWidgets(
|
||||||
|
'attached: search is the row\'s last item and never highlights',
|
||||||
|
(tester, theme, c) async {
|
||||||
|
var searchTaps = 0;
|
||||||
|
var selected = 0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
StatefulBuilder(
|
||||||
|
builder: (context, setState) => SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 80,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: selected,
|
||||||
|
onSelected: (i) => setState(() => selected = i),
|
||||||
|
searchDestination: const NooNavDestination(
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
onSearchTap: () => searchTaps++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
expect(find.byIcon(LucideIcons.search), findsOneWidget);
|
||||||
|
final iconCenters = [
|
||||||
|
tester.getCenter(find.byIcon(LucideIcons.folder)),
|
||||||
|
tester.getCenter(find.byIcon(LucideIcons.images)),
|
||||||
|
tester.getCenter(find.byIcon(LucideIcons.star)),
|
||||||
|
tester.getCenter(find.byIcon(LucideIcons.search)),
|
||||||
|
];
|
||||||
|
// Evenly spaced, search landing after every real destination.
|
||||||
|
for (var i = 1; i < iconCenters.length; i++) {
|
||||||
|
expect(iconCenters[i].dx, greaterThan(iconCenters[i - 1].dx));
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.search));
|
||||||
|
await tester.pump();
|
||||||
|
expect(searchTaps, 1);
|
||||||
|
// Tapping search never selects it or moves the indicator.
|
||||||
|
expect(selected, 0);
|
||||||
|
expect(_indicator(tester, c), findsOneWidget);
|
||||||
|
final indicatorLeft = tester.getTopLeft(_indicator(tester, c)).dx;
|
||||||
|
expect(indicatorLeft, lessThan(iconCenters[3].dx));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testNooWidgets('floating: search is a separate round satellite', (
|
||||||
|
tester,
|
||||||
|
theme,
|
||||||
|
c,
|
||||||
|
) async {
|
||||||
|
var searchTaps = 0;
|
||||||
|
await pumpNoo(
|
||||||
|
tester,
|
||||||
|
SizedBox(
|
||||||
|
width: 360,
|
||||||
|
height: 96,
|
||||||
|
child: NooBottomBar(
|
||||||
|
style: NooNavStyle.ios,
|
||||||
|
barStyle: NooBottomBarStyle.floating,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
searchDestination: const NooNavDestination(
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
onSearchTap: () => searchTaps++,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
theme: theme,
|
||||||
|
);
|
||||||
|
// Not part of the main pill - its own separately-tappable icon.
|
||||||
|
expect(find.text('Search'), findsNothing);
|
||||||
|
final satellite = tester.widget<Container>(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.byIcon(LucideIcons.search),
|
||||||
|
matching: find.byType(Container),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final decoration = satellite.decoration as BoxDecoration;
|
||||||
|
expect(decoration.shape, BoxShape.circle);
|
||||||
|
|
||||||
|
await tester.tap(find.byIcon(LucideIcons.search));
|
||||||
|
await tester.pump();
|
||||||
|
expect(searchTaps, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('floating with search sizes correctly in a real Scaffold '
|
||||||
|
'(regression: unbounded stretch used to fill the whole screen)', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
// A bare SizedBox host (as the other tests use) gives this widget a
|
||||||
|
// bounded height, masking this exact bug - Scaffold.bottomNavigationBar
|
||||||
|
// gives its child a *loose*, unbounded-max height instead, which is
|
||||||
|
// what a Row with crossAxisAlignment.stretch blows up against in
|
||||||
|
// profile/release (the debug assertion that would catch it is
|
||||||
|
// stripped there). Only a real Scaffold reproduces that.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
home: Scaffold(
|
||||||
|
body: const SizedBox.expand(),
|
||||||
|
bottomNavigationBar: NooBottomBar(
|
||||||
|
style: NooNavStyle.android,
|
||||||
|
barStyle: NooBottomBarStyle.floating,
|
||||||
|
destinations: _destinations,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelected: (_) {},
|
||||||
|
searchDestination: const NooNavDestination(
|
||||||
|
icon: LucideIcons.search,
|
||||||
|
label: 'Search',
|
||||||
|
),
|
||||||
|
onSearchTap: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final size = tester.getSize(find.byType(NooBottomBar));
|
||||||
|
expect(size.height, lessThan(150));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:noo/widgets/tabs/tab_state_slivers.dart';
|
||||||
|
|
||||||
|
import '../noo/noo_test_utils.dart';
|
||||||
|
|
||||||
|
/// A minimal stand-in for `AppTopBar` - just needs to be a fixed-height
|
||||||
|
/// [PreferredSizeWidget], same contract `topBarSliver` wraps in real tabs.
|
||||||
|
class _FakeTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
const _FakeTopBar();
|
||||||
|
|
||||||
|
static const double height = 56;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(height);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: ColoredBox(color: Colors.blue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matches the one sliver `topBarSliver` inserts, even once it's fully
|
||||||
|
/// scrolled away - `find.byType`'s default `skipOffstage: true` treats a
|
||||||
|
/// sliver with `geometry.visible == false` (paintExtent 0, exactly the
|
||||||
|
/// "fully hidden" state part of this test exercises) as offstage and
|
||||||
|
/// excludes it, which would otherwise make `tester.renderObject` throw
|
||||||
|
/// instead of reporting the 0 this test expects. A function, not a
|
||||||
|
/// top-level constant, so each call re-evaluates against whichever widget
|
||||||
|
/// tree is currently pumped.
|
||||||
|
Finder _header() => find.byType(SliverFloatingHeader, skipOffstage: false);
|
||||||
|
|
||||||
|
/// A minimal stand-in for each tab's own pinned sort/filter row
|
||||||
|
/// (`StickyHeaderDelegate`) - just needs to be a `SliverPersistentHeader`
|
||||||
|
/// with `pinned: true` below `topBarSliver`, same contract every real tab
|
||||||
|
/// view uses.
|
||||||
|
class _FakeStickyHeader extends StatelessWidget {
|
||||||
|
const _FakeStickyHeader();
|
||||||
|
|
||||||
|
static const double height = 48;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => const SizedBox(
|
||||||
|
height: height,
|
||||||
|
child: ColoredBox(color: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeStickyHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||||
|
@override
|
||||||
|
double get minExtent => _FakeStickyHeader.height;
|
||||||
|
@override
|
||||||
|
double get maxExtent => _FakeStickyHeader.height;
|
||||||
|
@override
|
||||||
|
Widget build(
|
||||||
|
BuildContext context,
|
||||||
|
double shrinkOffset,
|
||||||
|
bool overlapsContent,
|
||||||
|
) => const _FakeStickyHeader();
|
||||||
|
@override
|
||||||
|
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
|
||||||
|
false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpHost(WidgetTester tester, ScrollController controller) {
|
||||||
|
return tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
home: Scaffold(
|
||||||
|
body: CustomScrollView(
|
||||||
|
controller: controller,
|
||||||
|
slivers: [
|
||||||
|
topBarSliver(const _FakeTopBar()),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) =>
|
||||||
|
SizedBox(height: 60, child: Text('Item $index')),
|
||||||
|
childCount: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpNooTests();
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'topBarSliver floats away scrolling down and reappears the moment the '
|
||||||
|
'drag reverses, not only once back at the top (Material floating app '
|
||||||
|
'bar behavior)',
|
||||||
|
(tester) async {
|
||||||
|
final controller = ScrollController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await _pumpHost(tester, controller);
|
||||||
|
|
||||||
|
// The render object's own `geometry.paintExtent` is the ground truth
|
||||||
|
// for how much of the bar is actually visible - more reliable than
|
||||||
|
// inferring visibility from hit-testing/finding the child, which a
|
||||||
|
// floating header sliver keeps built regardless of paint extent.
|
||||||
|
double paintExtent() =>
|
||||||
|
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent;
|
||||||
|
|
||||||
|
// Fully visible before any scroll.
|
||||||
|
expect(paintExtent(), _FakeTopBar.height);
|
||||||
|
expect(controller.offset, 0);
|
||||||
|
|
||||||
|
// Drive one continuous drag by hand (rather than `tester.drag`, which
|
||||||
|
// only pumps once the whole gesture - including the final pointer-up
|
||||||
|
// - has already finished): the floating header's reveal-while-
|
||||||
|
// dragging behavior keys off the *live* scroll direction as reported
|
||||||
|
// mid-gesture, so this needs an intermediate pump while the pointer
|
||||||
|
// is still down to actually observe it.
|
||||||
|
final gesture = await tester.startGesture(const Offset(200, 300));
|
||||||
|
|
||||||
|
// Drag up (content scrolls down) well past the bar's own height, so
|
||||||
|
// it scrolls fully away, same as a plain `SliverToBoxAdapter` would.
|
||||||
|
await gesture.moveBy(const Offset(0, -200));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(paintExtent(), 0);
|
||||||
|
final scrolledOffset = controller.offset;
|
||||||
|
expect(
|
||||||
|
scrolledOffset,
|
||||||
|
greaterThan(_FakeTopBar.height),
|
||||||
|
reason: "the drag should have moved past the bar's own height",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reverse direction by a small amount, still mid-gesture and nowhere
|
||||||
|
// near the top of the list. A plain `SliverToBoxAdapter`, or a header
|
||||||
|
// that only reappears once back at the top, would stay fully hidden
|
||||||
|
// here. The floating bar should start reappearing immediately
|
||||||
|
// instead, following the drag.
|
||||||
|
await gesture.moveBy(const Offset(0, 40));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(controller.offset, lessThan(scrolledOffset));
|
||||||
|
expect(
|
||||||
|
controller.offset,
|
||||||
|
greaterThan(_FakeTopBar.height / 2),
|
||||||
|
reason: 'still far from the top of the list',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
paintExtent(),
|
||||||
|
greaterThan(0),
|
||||||
|
reason:
|
||||||
|
'the bar should already be reappearing on the first upward '
|
||||||
|
'movement, without needing to reach the top',
|
||||||
|
);
|
||||||
|
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
"topBarSliver sizes itself from the wrapped bar's own layout (e.g. its "
|
||||||
|
'SafeArea-padded height), not a value declared up front',
|
||||||
|
(tester) async {
|
||||||
|
final controller = ScrollController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
await _pumpHost(tester, controller);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byType(_FakeTopBar)).height,
|
||||||
|
_FakeTopBar.height,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'a pinned header below topBarSliver stays clear of the status bar once '
|
||||||
|
'the floating bar fully collapses (regression: it used to ride up '
|
||||||
|
'underneath the status bar once the only thing reserving that space '
|
||||||
|
"disappeared along with the bar's own height)",
|
||||||
|
(tester) async {
|
||||||
|
final controller = ScrollController();
|
||||||
|
addTearDown(controller.dispose);
|
||||||
|
const statusBarHeight = 40.0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MediaQuery(
|
||||||
|
data: const MediaQueryData(
|
||||||
|
padding: EdgeInsets.only(top: statusBarHeight),
|
||||||
|
),
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: nooTheme(Brightness.light),
|
||||||
|
home: Scaffold(
|
||||||
|
// The fix under test: wrapping the scroll view (not just the
|
||||||
|
// top bar) in `SafeArea(top: true)` reserves the status-bar
|
||||||
|
// inset outside the scrolling/collapsing region entirely, so
|
||||||
|
// it's never implicated in `topBarSliver`'s own collapse math.
|
||||||
|
body: SafeArea(
|
||||||
|
top: true,
|
||||||
|
bottom: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: controller,
|
||||||
|
slivers: [
|
||||||
|
topBarSliver(const _FakeTopBar()),
|
||||||
|
SliverPersistentHeader(
|
||||||
|
pinned: true,
|
||||||
|
delegate: _FakeStickyHeaderDelegate(),
|
||||||
|
),
|
||||||
|
SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) =>
|
||||||
|
SizedBox(height: 60, child: Text('Item $index')),
|
||||||
|
childCount: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fully visible before any scroll - right below the reserved inset.
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
|
||||||
|
_FakeTopBar.height + statusBarHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Scroll well past the top bar's own height so it collapses fully.
|
||||||
|
final gesture = await tester.startGesture(const Offset(200, 300));
|
||||||
|
await gesture.moveBy(const Offset(0, -300));
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
tester.renderObject<RenderSliver>(_header()).geometry!.paintExtent,
|
||||||
|
0,
|
||||||
|
reason:
|
||||||
|
'the top bar should be fully collapsed for this check to '
|
||||||
|
'mean anything',
|
||||||
|
);
|
||||||
|
// The regression: without the fix, this would be 0 (or negative,
|
||||||
|
// scrolled up under the status bar) instead of sitting right at the
|
||||||
|
// reserved inset.
|
||||||
|
expect(
|
||||||
|
tester.getTopLeft(find.byType(_FakeStickyHeader)).dy,
|
||||||
|
statusBarHeight,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// Host-side driver for integration_test/store_screenshots_test.dart (run via
|
|
||||||
// tool/screenshots.sh). Each time the test asks for a screenshot this saves
|
|
||||||
// a PNG named after it into $SCREENSHOT_DIR.
|
|
||||||
//
|
|
||||||
// These bytes are the Flutter surface only (no OS status bar) - an earlier
|
|
||||||
// version of this file tried to re-capture the screen with `adb exec-out
|
|
||||||
// screencap` for a "real" screenshot including it, but on Android,
|
|
||||||
// `onScreenshot` only fires once the whole test has finished (see
|
|
||||||
// `IntegrationTestWidgetsFlutterBinding`/`integrationDriver`'s source: the
|
|
||||||
// native platform channel hands screenshot bytes straight to `reportData`
|
|
||||||
// with no round trip to the driver, and the driver's first `requestData`
|
|
||||||
// call blocks until every `testWidgets` body in the file has returned) - so
|
|
||||||
// every `adb screencap` call was capturing the same final frame, regardless
|
|
||||||
// of which named screenshot it was meant to be. Saving these bytes directly
|
|
||||||
// is what actually gives each name its own distinct image.
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:integration_test/integration_test_driver_extended.dart';
|
|
||||||
|
|
||||||
Future<void> main() async {
|
|
||||||
final outDir = Directory(
|
|
||||||
Platform.environment['SCREENSHOT_DIR'] ?? 'store_listing/screenshots',
|
|
||||||
)..createSync(recursive: true);
|
|
||||||
|
|
||||||
await integrationDriver(
|
|
||||||
onScreenshot:
|
|
||||||
(String name, List<int> bytes, [Map<String, Object?>? args]) async {
|
|
||||||
File('${outDir.path}/$name.png').writeAsBytesSync(bytes);
|
|
||||||
stdout.writeln('saved $name.png');
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# Demo server for Play Store screenshots
|
|
||||||
|
|
||||||
`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway
|
|
||||||
Nextcloud that only contains fake data**, so nothing real can appear in them.
|
|
||||||
This folder is that server:
|
|
||||||
|
|
||||||
- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing
|
|
||||||
persisted; `docker compose down -v` wipes it).
|
|
||||||
- `seed.py` - creates the users and fills them with invented content: landscape
|
|
||||||
"photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites,
|
|
||||||
shares (to a second user and a public link), trash, a filler file for the
|
|
||||||
storage quota, and edits by a second user so the Activity feed has more than
|
|
||||||
one author. It ends by printing `APP_PASSWORD=...` - an app password for the
|
|
||||||
demo user, so the app can start logged in without the browser login step.
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
Most of the time just run the whole pipeline from the repo root:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash tool/screenshots.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter**
|
|
||||||
and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed
|
|
||||||
into `tool/demo_server/.venv` automatically if your Python lacks them. The
|
|
||||||
script installs the emulator and a system image through `sdkmanager` on first
|
|
||||||
use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`,
|
|
||||||
`ANDROID_SERIAL`, ...) are documented at the top of the script.
|
|
||||||
|
|
||||||
To poke at the server yourself:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f tool/demo_server/docker-compose.yml up -d
|
|
||||||
python3 tool/demo_server/seed.py # prints APP_PASSWORD=...
|
|
||||||
# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password)
|
|
||||||
docker compose -f tool/demo_server/docker-compose.yml down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated
|
|
||||||
files to `DIR` without any server - handy for previewing the fake content.
|
|
||||||
|
|
||||||
## Before you upload
|
|
||||||
|
|
||||||
Look at every image. The known places where real data could still show up:
|
|
||||||
the server address and username (sync header panel, Settings > Accounts), the
|
|
||||||
avatar initial, notification icons in the status bar, and anything left over
|
|
||||||
from a previously used emulator profile (the script always wipes its own).
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# A throwaway Nextcloud that only ever holds fake data - the backend the Play
|
|
||||||
# Store screenshots are taken against (see tool/screenshots.sh). Nothing is
|
|
||||||
# persisted: `docker compose down -v` wipes it completely.
|
|
||||||
services:
|
|
||||||
nextcloud:
|
|
||||||
image: ${NEXTCLOUD_IMAGE:-nextcloud:apache}
|
|
||||||
ports:
|
|
||||||
- "${DEMO_PORT:-80}:80"
|
|
||||||
environment:
|
|
||||||
# SQLite keeps this to a single container.
|
|
||||||
SQLITE_DATABASE: nextcloud
|
|
||||||
NEXTCLOUD_ADMIN_USER: admin
|
|
||||||
NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password
|
|
||||||
# `localhost` is how the Android emulator reaches this server (via
|
|
||||||
# `adb reverse`); DEMO_HOST is the optional friendlier hostname.
|
|
||||||
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}"
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
requests>=2.31
|
|
||||||
Pillow>=10.0
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots.
|
|
||||||
|
|
||||||
Everything here is invented or procedurally generated (landscape "photos"
|
|
||||||
drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real -
|
|
||||||
and nothing with a licence or a face in it - can end up in a screenshot.
|
|
||||||
|
|
||||||
python3 seed.py seed http://localhost (DEMO_BASE_URL)
|
|
||||||
python3 seed.py --generate-only D write the fake files under D, no server
|
|
||||||
|
|
||||||
On success the LAST line printed to stdout is `APP_PASSWORD=<token>` - an app
|
|
||||||
password for the demo user, which tool/screenshots.sh hands to the app so it
|
|
||||||
can start logged in without going through the browser login flow. All
|
|
||||||
progress output goes to stderr.
|
|
||||||
"""
|
|
||||||
import argparse
|
|
||||||
import io
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import random
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFilter
|
|
||||||
|
|
||||||
BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/")
|
|
||||||
ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password"))
|
|
||||||
FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300"))
|
|
||||||
|
|
||||||
# The account the screenshots are taken as, and a second user to share with.
|
|
||||||
USERS = {
|
|
||||||
"Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"),
|
|
||||||
"Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def log(message):
|
|
||||||
print(message, file=sys.stderr, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- content ---
|
|
||||||
|
|
||||||
# (sky top, sky bottom, sun, far hills, mid hills, near hills)
|
|
||||||
PALETTES = [
|
|
||||||
((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)),
|
|
||||||
((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)),
|
|
||||||
((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)),
|
|
||||||
((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)),
|
|
||||||
((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)),
|
|
||||||
((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)),
|
|
||||||
((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)),
|
|
||||||
((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _mix(a, b, t):
|
|
||||||
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
|
||||||
|
|
||||||
|
|
||||||
def landscape(seed, size=(1600, 1067)):
|
|
||||||
"""A soft, layered-hills landscape - reads as a photo at thumbnail size."""
|
|
||||||
rnd = random.Random(seed)
|
|
||||||
top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)]
|
|
||||||
w, h = size
|
|
||||||
img = Image.new("RGB", size)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
for y in range(h):
|
|
||||||
draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75))))
|
|
||||||
sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7)
|
|
||||||
draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun)
|
|
||||||
for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)):
|
|
||||||
phase = [rnd.uniform(0, math.tau) for _ in range(3)]
|
|
||||||
freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)]
|
|
||||||
weight = (1.0, 0.5, 0.2)
|
|
||||||
pts = [(0, h)]
|
|
||||||
for x in range(0, w + 8, 8):
|
|
||||||
v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase))
|
|
||||||
pts.append((x, int(h * base + h * amp * v / 1.7)))
|
|
||||||
pts.append((w, h))
|
|
||||||
draw.polygon(pts, fill=color)
|
|
||||||
img = img.filter(ImageFilter.GaussianBlur(0.8))
|
|
||||||
out = io.BytesIO()
|
|
||||||
img.save(out, "JPEG", quality=86)
|
|
||||||
return out.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def pdf(title, lines):
|
|
||||||
"""A tiny but valid one-page PDF."""
|
|
||||||
|
|
||||||
def esc(s):
|
|
||||||
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
|
||||||
|
|
||||||
body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title)
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line))
|
|
||||||
stream = body.encode("latin-1", "replace")
|
|
||||||
objs = [
|
|
||||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
|
||||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
||||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
|
|
||||||
b"/Resources << /Font << /F1 5 0 R >> >> >>",
|
|
||||||
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
|
|
||||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
||||||
]
|
|
||||||
out = bytearray(b"%PDF-1.4\n")
|
|
||||||
offsets = []
|
|
||||||
for i, obj in enumerate(objs, start=1):
|
|
||||||
offsets.append(len(out))
|
|
||||||
out += b"%d 0 obj\n" % i + obj + b"\nendobj\n"
|
|
||||||
xref = len(out)
|
|
||||||
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
|
|
||||||
for off in offsets:
|
|
||||||
out += b"%010d 00000 n \n" % off
|
|
||||||
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def text(body):
|
|
||||||
return body.strip().encode("utf-8") + b"\n"
|
|
||||||
|
|
||||||
|
|
||||||
def video():
|
|
||||||
"""A short test-pattern clip if ffmpeg is around, else None (skipped)."""
|
|
||||||
if not shutil.which("ffmpeg"):
|
|
||||||
return None
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
path = os.path.join(tmp, "clip.mp4")
|
|
||||||
result = subprocess.run(
|
|
||||||
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24",
|
|
||||||
"-pix_fmt", "yuv420p", path],
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return None
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def build_content():
|
|
||||||
"""(path, bytes) for everything the demo user owns, plus what to favorite/delete."""
|
|
||||||
files = []
|
|
||||||
|
|
||||||
def add(path, data):
|
|
||||||
if data is not None:
|
|
||||||
files.append((path, data))
|
|
||||||
|
|
||||||
add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go."))
|
|
||||||
add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter"))
|
|
||||||
|
|
||||||
add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"]))
|
|
||||||
add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"]))
|
|
||||||
add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite"))
|
|
||||||
add("/Documents/Budget 2026.xlsx", os.urandom(24_000))
|
|
||||||
add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"]))
|
|
||||||
add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"]))
|
|
||||||
|
|
||||||
trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"]
|
|
||||||
for i, name in enumerate(trips):
|
|
||||||
add("/Trips/%s.jpg" % name, landscape(i + 1))
|
|
||||||
add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"]))
|
|
||||||
|
|
||||||
for i in range(8):
|
|
||||||
add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i))
|
|
||||||
add("/Photos/Summer 2026/Clip - hike.mp4", video())
|
|
||||||
|
|
||||||
add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper."))
|
|
||||||
add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt."))
|
|
||||||
add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans"))
|
|
||||||
|
|
||||||
add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"]))
|
|
||||||
add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}'))
|
|
||||||
add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets."))
|
|
||||||
|
|
||||||
favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"]
|
|
||||||
|
|
||||||
# Created, then deleted - they end up in the Trash tab.
|
|
||||||
trashed = [
|
|
||||||
("/Old draft.txt", text("Superseded draft.")),
|
|
||||||
("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))),
|
|
||||||
("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])),
|
|
||||||
]
|
|
||||||
return files, favorites, trashed
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- server ---
|
|
||||||
|
|
||||||
|
|
||||||
class ZeroStream:
|
|
||||||
"""`size` zero bytes, streamed - a big filler file without holding it in memory."""
|
|
||||||
|
|
||||||
def __init__(self, size):
|
|
||||||
self.size, self.pos = size, 0
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return self.size
|
|
||||||
|
|
||||||
def read(self, n=-1):
|
|
||||||
n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos)
|
|
||||||
self.pos += n
|
|
||||||
return b"\0" * n
|
|
||||||
|
|
||||||
|
|
||||||
class Server:
|
|
||||||
def __init__(self):
|
|
||||||
import requests # imported lazily so --generate-only needs no network libs
|
|
||||||
|
|
||||||
self.requests = requests
|
|
||||||
|
|
||||||
def _call(self, method, url, auth, **kw):
|
|
||||||
headers = kw.pop("headers", {})
|
|
||||||
headers.setdefault("OCS-APIRequest", "true")
|
|
||||||
return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw)
|
|
||||||
|
|
||||||
def wait_ready(self):
|
|
||||||
log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...")
|
|
||||||
deadline = time.time() + 600
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
r = self.requests.get(BASE + "/status.php", timeout=5)
|
|
||||||
if r.ok and r.json().get("installed") and not r.json().get("maintenance"):
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
time.sleep(3)
|
|
||||||
sys.exit("Nextcloud did not become ready in time")
|
|
||||||
|
|
||||||
def ocs(self, method, path, auth, **kw):
|
|
||||||
r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw)
|
|
||||||
try:
|
|
||||||
return r.json()["ocs"]
|
|
||||||
except Exception:
|
|
||||||
return {"meta": {"statuscode": r.status_code}, "data": {}}
|
|
||||||
|
|
||||||
def create_user(self, user, password, display, email, quota):
|
|
||||||
res = self.ocs(
|
|
||||||
"POST", "/ocs/v1.php/cloud/users", ADMIN,
|
|
||||||
data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota},
|
|
||||||
)
|
|
||||||
code = res["meta"]["statuscode"]
|
|
||||||
if code not in (100, 200, 102): # 102 = already exists
|
|
||||||
sys.exit("Could not create user %s: %s" % (user, res))
|
|
||||||
|
|
||||||
def dav(self, method, user, path, **kw):
|
|
||||||
auth = (user, USERS[user][0])
|
|
||||||
url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path))
|
|
||||||
return self._call(method, url, auth, **kw)
|
|
||||||
|
|
||||||
def mkdirs(self, user, path):
|
|
||||||
built = ""
|
|
||||||
for part in [p for p in path.split("/") if p]:
|
|
||||||
built += "/" + part
|
|
||||||
self.dav("MKCOL", user, built)
|
|
||||||
|
|
||||||
def put(self, user, path, data):
|
|
||||||
parent = path.rsplit("/", 1)[0]
|
|
||||||
if parent:
|
|
||||||
self.mkdirs(user, parent)
|
|
||||||
r = self.dav("PUT", user, path, data=data)
|
|
||||||
if r.status_code not in (200, 201, 204):
|
|
||||||
sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200]))
|
|
||||||
|
|
||||||
def favorite(self, user, path):
|
|
||||||
body = (
|
|
||||||
'<?xml version="1.0"?><d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
|
|
||||||
"<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set></d:propertyupdate>"
|
|
||||||
)
|
|
||||||
self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"})
|
|
||||||
|
|
||||||
def delete(self, user, path):
|
|
||||||
self.dav("DELETE", user, path)
|
|
||||||
|
|
||||||
def share(self, user, path, share_type, share_with=None, permissions=31):
|
|
||||||
data = {"path": path, "shareType": share_type, "permissions": permissions}
|
|
||||||
if share_with:
|
|
||||||
data["shareWith"] = share_with
|
|
||||||
auth = (user, USERS[user][0])
|
|
||||||
self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data)
|
|
||||||
|
|
||||||
def app_password(self, user):
|
|
||||||
auth = (user, USERS[user][0])
|
|
||||||
res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth)
|
|
||||||
token = (res.get("data") or {}).get("apppassword")
|
|
||||||
if not token:
|
|
||||||
sys.exit("Could not get an app password for %s: %s" % (user, res))
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def seed_server():
|
|
||||||
server = Server()
|
|
||||||
server.wait_ready()
|
|
||||||
|
|
||||||
for user, (password, display, email, quota) in USERS.items():
|
|
||||||
log("Creating user %s" % user)
|
|
||||||
server.create_user(user, password, display, email, quota)
|
|
||||||
|
|
||||||
files, favorites, trashed = build_content()
|
|
||||||
log("Uploading %d files as Alex" % len(files))
|
|
||||||
for path, data in files:
|
|
||||||
server.put("Alex", path, data)
|
|
||||||
if FILLER_MB > 0:
|
|
||||||
log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB)
|
|
||||||
server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024))
|
|
||||||
|
|
||||||
for path in favorites:
|
|
||||||
server.favorite("Alex", path)
|
|
||||||
|
|
||||||
log("Creating shares")
|
|
||||||
server.share("Alex", "/Trips", 0, "Sam", permissions=31)
|
|
||||||
server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1)
|
|
||||||
server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch."))
|
|
||||||
server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30."))
|
|
||||||
server.share("Sam", "/Team notes", 0, "Alex", permissions=31)
|
|
||||||
# Sam edits inside the folder Alex shared - gives the Activity feed a second author.
|
|
||||||
server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!"))
|
|
||||||
|
|
||||||
log("Filling the trash")
|
|
||||||
for path, data in trashed:
|
|
||||||
server.put("Alex", path, data)
|
|
||||||
for path, _ in trashed:
|
|
||||||
server.delete("Alex", path)
|
|
||||||
server.delete("Alex", "/Receipts (old)")
|
|
||||||
|
|
||||||
token = server.app_password("Alex")
|
|
||||||
print("APP_PASSWORD=" + token, flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_only(target):
|
|
||||||
files, favorites, trashed = build_content()
|
|
||||||
for path, data in files + trashed:
|
|
||||||
dest = os.path.join(target, "Alex" + path)
|
|
||||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
||||||
with open(dest, "wb") as f:
|
|
||||||
f.write(data)
|
|
||||||
log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites)))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
||||||
parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server")
|
|
||||||
args = parser.parse_args()
|
|
||||||
if args.generate_only:
|
|
||||||
generate_only(args.generate_only)
|
|
||||||
else:
|
|
||||||
seed_server()
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Makes captured screenshots Play Store ready and reports anything that isn't.
|
|
||||||
|
|
||||||
Play wants JPEG or 24-bit PNG (no alpha), each side 320-3840 px, and the long
|
|
||||||
side at most twice the short side. The captured PNGs are 32-bit RGBA, so this
|
|
||||||
flattens them to RGB in place, then checks the dimensions.
|
|
||||||
|
|
||||||
python3 tool/finalize_screenshots.py store_listing/screenshots
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
|
|
||||||
def main(directory):
|
|
||||||
files = sorted(Path(directory).glob("*.png"))
|
|
||||||
if not files:
|
|
||||||
sys.exit("No PNGs found in %s" % directory)
|
|
||||||
|
|
||||||
problems = 0
|
|
||||||
for path in files:
|
|
||||||
with Image.open(path) as im:
|
|
||||||
im.load()
|
|
||||||
rgb = Image.new("RGB", im.size, (255, 255, 255))
|
|
||||||
rgb.paste(im, mask=im.getchannel("A") if "A" in im.getbands() else None)
|
|
||||||
rgb.save(path, "PNG", optimize=True)
|
|
||||||
|
|
||||||
w, h = rgb.size
|
|
||||||
notes = []
|
|
||||||
if min(w, h) < 320 or max(w, h) > 3840:
|
|
||||||
notes.append("side outside 320-3840 px")
|
|
||||||
if max(w, h) > 2 * min(w, h):
|
|
||||||
notes.append("aspect ratio over 2:1")
|
|
||||||
problems += bool(notes)
|
|
||||||
print("%-32s %4dx%-4d %s" % (path.name, w, h, "; ".join(notes) or "ok"))
|
|
||||||
|
|
||||||
print("\n%d screenshots, %d with problems" % (len(files), problems))
|
|
||||||
sys.exit(1 if problems else 0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main(sys.argv[1] if len(sys.argv) > 1 else "store_listing/screenshots")
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Generates the Play Store screenshots - without any real data.
|
|
||||||
#
|
|
||||||
# What it does, end to end:
|
|
||||||
# 1. starts a throwaway Nextcloud in Docker and fills it with fake data
|
|
||||||
# 2. creates + boots a clean Android emulator (1080x1920, demo-mode status bar)
|
|
||||||
# 3. runs integration_test/store_screenshots_test.dart against it, which
|
|
||||||
# logs in as the fake user and walks through the app's tabs and states
|
|
||||||
# 4. leaves flattened, size-checked PNGs in store_listing/screenshots/
|
|
||||||
#
|
|
||||||
# Works on Linux, macOS and Git Bash on Windows. Needs: docker, python3,
|
|
||||||
# flutter, and an Android SDK (ANDROID_HOME / ANDROID_SDK_ROOT). See
|
|
||||||
# tool/demo_server/README.md.
|
|
||||||
#
|
|
||||||
# Options (environment variables):
|
|
||||||
# SCREENSHOT_DIR output directory (store_listing/screenshots)
|
|
||||||
# ANDROID_SERIAL use this already-running device instead of the emulator
|
|
||||||
# HEADLESS=1 run the emulator without a window
|
|
||||||
# DEMO_PORT host port for the demo server (80; anything else shows
|
|
||||||
# up as ":port" in the app's header)
|
|
||||||
# DEMO_HOST show this hostname instead of "localhost" in the app,
|
|
||||||
# e.g. cloud.example.com (best effort - edits the
|
|
||||||
# emulator's hosts file, which needs a rootable image)
|
|
||||||
# KEEP_SERVER=1 leave the demo server running afterwards
|
|
||||||
# KEEP_EMULATOR=1 leave the emulator running afterwards
|
|
||||||
# SCREEN_W / SCREEN_H / SCREEN_DENSITY emulator screen (1080 / 1920 / 440);
|
|
||||||
# Play needs the long side <= 2x the short side
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
|
||||||
REPO_ROOT="$PWD"
|
|
||||||
|
|
||||||
# Git Bash would otherwise rewrite device paths such as /system/etc/hosts.
|
|
||||||
export MSYS_NO_PATHCONV=1
|
|
||||||
|
|
||||||
AVD_NAME="${AVD_NAME:-noo_screenshots}"
|
|
||||||
API_LEVEL="${SCREENSHOT_API:-36}"
|
|
||||||
SYSTEM_IMAGE="system-images;android-${API_LEVEL};google_apis;x86_64"
|
|
||||||
SCREEN_W="${SCREEN_W:-1080}"
|
|
||||||
SCREEN_H="${SCREEN_H:-1920}"
|
|
||||||
SCREEN_DENSITY="${SCREEN_DENSITY:-440}"
|
|
||||||
OUT_DIR="${SCREENSHOT_DIR:-store_listing/screenshots}"
|
|
||||||
DEMO_PORT="${DEMO_PORT:-80}"
|
|
||||||
DEMO_HOST="${DEMO_HOST:-}"
|
|
||||||
export DEMO_PORT DEMO_HOST
|
|
||||||
|
|
||||||
log() { printf '\n==> %s\n' "$*" >&2; }
|
|
||||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
|
||||||
|
|
||||||
# Two easy mistakes worth catching up front rather than as a confusing
|
|
||||||
# "missing prerequisites" list.
|
|
||||||
if [ "$(id -u)" = 0 ]; then
|
|
||||||
die "don't run this as root/with sudo - it would create root-owned files and use root's home for the emulator."
|
|
||||||
fi
|
|
||||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
|
||||||
die "this is WSL, which can't see your Windows Flutter/Android SDK (and can't run the emulator well). On Windows run it from Git Bash instead."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- tooling ---
|
|
||||||
|
|
||||||
# Windows env vars hold C:\... paths; convert for bash.
|
|
||||||
to_unix() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1"; else printf '%s' "$1"; fi; }
|
|
||||||
|
|
||||||
SDK_ROOT=""
|
|
||||||
for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" \
|
|
||||||
"$HOME/Library/Android/sdk" "${LOCALAPPDATA:-}/Android/Sdk"; do
|
|
||||||
if [ -n "$candidate" ] && [ -d "$(to_unix "$candidate")" ]; then
|
|
||||||
SDK_ROOT="$(to_unix "$candidate")"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Finds a tool by name on PATH or in the SDK, including Windows .bat/.exe.
|
|
||||||
find_tool() {
|
|
||||||
local name="$1" dir ext
|
|
||||||
for ext in "" .bat .exe; do
|
|
||||||
if command -v "$name$ext" >/dev/null 2>&1; then command -v "$name$ext"; return 0; fi
|
|
||||||
done
|
|
||||||
for dir in "$SDK_ROOT/cmdline-tools/latest/bin" "$SDK_ROOT/platform-tools" "$SDK_ROOT/emulator"; do
|
|
||||||
for ext in "" .bat .exe; do
|
|
||||||
if [ -x "$dir/$name$ext" ]; then printf '%s' "$dir/$name$ext"; return 0; fi
|
|
||||||
done
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
missing=()
|
|
||||||
require() { # <command> <how to get it>
|
|
||||||
command -v "$1" >/dev/null 2>&1 || missing+=("$1 - $2")
|
|
||||||
}
|
|
||||||
require docker "install Docker (Desktop on Windows/macOS)"
|
|
||||||
require flutter "install Flutter and put it on PATH"
|
|
||||||
PY="$(command -v python3 || command -v python || true)"
|
|
||||||
[ -n "$PY" ] || missing+=("python3 - install Python 3")
|
|
||||||
ADB="$(find_tool adb || true)"
|
|
||||||
[ -n "$ADB" ] || missing+=("adb - install Android platform-tools (or set ANDROID_HOME)")
|
|
||||||
if [ -z "${ANDROID_SERIAL:-}" ]; then
|
|
||||||
SDKMANAGER="$(find_tool sdkmanager || true)"
|
|
||||||
AVDMANAGER="$(find_tool avdmanager || true)"
|
|
||||||
[ -n "$SDKMANAGER" ] && [ -n "$AVDMANAGER" ] ||
|
|
||||||
missing+=("sdkmanager/avdmanager - install the Android cmdline-tools (or set ANDROID_HOME)")
|
|
||||||
fi
|
|
||||||
if [ "${#missing[@]}" -gt 0 ]; then
|
|
||||||
printf 'Missing prerequisites:\n' >&2
|
|
||||||
printf ' - %s\n' "${missing[@]}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if docker compose version >/dev/null 2>&1; then
|
|
||||||
DC=(docker compose)
|
|
||||||
else
|
|
||||||
DC=(docker-compose)
|
|
||||||
fi
|
|
||||||
DC+=(-f tool/demo_server/docker-compose.yml)
|
|
||||||
adb() { "$ADB" "$@"; }
|
|
||||||
|
|
||||||
# Python with Pillow + requests: use the system one if it has them, else a
|
|
||||||
# local virtualenv (tool/demo_server/.venv, gitignored).
|
|
||||||
if ! "$PY" -c 'import PIL, requests' >/dev/null 2>&1; then
|
|
||||||
log "Setting up a Python virtualenv (Pillow, requests)"
|
|
||||||
"$PY" -m venv tool/demo_server/.venv
|
|
||||||
if [ -x tool/demo_server/.venv/bin/python ]; then
|
|
||||||
PY="$REPO_ROOT/tool/demo_server/.venv/bin/python"
|
|
||||||
else
|
|
||||||
PY="$REPO_ROOT/tool/demo_server/.venv/Scripts/python"
|
|
||||||
fi
|
|
||||||
"$PY" -m pip install -q -r tool/demo_server/requirements.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- cleanup ---
|
|
||||||
|
|
||||||
STARTED_EMULATOR=0
|
|
||||||
EMULATOR_PID=""
|
|
||||||
cleanup() {
|
|
||||||
local code=$?
|
|
||||||
set +e
|
|
||||||
if [ -n "${SERIAL:-}" ]; then
|
|
||||||
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1
|
|
||||||
adb -s "$SERIAL" reverse --remove-all >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
if [ "$STARTED_EMULATOR" = 1 ] && [ "${KEEP_EMULATOR:-0}" != 1 ]; then
|
|
||||||
adb -s "$SERIAL" emu kill >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
if [ "${KEEP_SERVER:-0}" != 1 ]; then
|
|
||||||
"${DC[@]}" down -v >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
exit "$code"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- demo server ---
|
|
||||||
|
|
||||||
log "Starting a fresh demo Nextcloud (port $DEMO_PORT)"
|
|
||||||
"${DC[@]}" down -v >/dev/null 2>&1 || true
|
|
||||||
"${DC[@]}" up -d
|
|
||||||
|
|
||||||
log "Seeding fake data"
|
|
||||||
APP_PASSWORD="$(DEMO_BASE_URL="http://localhost:$DEMO_PORT" "$PY" tool/demo_server/seed.py | grep '^APP_PASSWORD=' | tail -n1 | cut -d= -f2-)"
|
|
||||||
[ -n "$APP_PASSWORD" ] || die "the seed script did not return an app password"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- device ---
|
|
||||||
|
|
||||||
if [ -n "${ANDROID_SERIAL:-}" ]; then
|
|
||||||
SERIAL="$ANDROID_SERIAL"
|
|
||||||
log "Using existing device $SERIAL"
|
|
||||||
else
|
|
||||||
log "Preparing the emulator"
|
|
||||||
[ -n "$SDK_ROOT" ] || die "set ANDROID_HOME to your Android SDK"
|
|
||||||
EMULATOR="$(find_tool emulator || true)"
|
|
||||||
if [ -z "$EMULATOR" ] || ! [ -d "$SDK_ROOT/system-images/android-${API_LEVEL}/google_apis/x86_64" ]; then
|
|
||||||
log "Installing the emulator + system image (one-off, a few GB)"
|
|
||||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
|
||||||
"$SDKMANAGER" "emulator" "platform-tools" "$SYSTEM_IMAGE"
|
|
||||||
EMULATOR="$(find_tool emulator)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# avdmanager also prints a harmless "Could not load devices from
|
|
||||||
# .../devices.xml" error for system images that ship no devices.xml, so its
|
|
||||||
# output is hidden and success is judged by whether the AVD exists.
|
|
||||||
echo no | "$AVDMANAGER" create avd --force -n "$AVD_NAME" -k "$SYSTEM_IMAGE" -d pixel_6 >/dev/null 2>&1 || true
|
|
||||||
AVD_DIR="$(to_unix "${ANDROID_AVD_HOME:-$HOME/.android/avd}")/$AVD_NAME.avd"
|
|
||||||
[ -d "$AVD_DIR" ] || die "could not find the AVD at $AVD_DIR (set ANDROID_AVD_HOME)"
|
|
||||||
set_ini() { # <key> <value>: replace or append in the AVD's config.ini
|
|
||||||
if grep -q "^$1=" "$AVD_DIR/config.ini"; then
|
|
||||||
sed -i.bak "s|^$1=.*|$1=$2|" "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
|
|
||||||
else
|
|
||||||
echo "$1=$2" >> "$AVD_DIR/config.ini"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
sed -i.bak '/^skin\.path=/d' "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
|
|
||||||
set_ini skin.name "${SCREEN_W}x${SCREEN_H}"
|
|
||||||
set_ini hw.lcd.width "$SCREEN_W"
|
|
||||||
set_ini hw.lcd.height "$SCREEN_H"
|
|
||||||
set_ini hw.lcd.density "$SCREEN_DENSITY"
|
|
||||||
|
|
||||||
log "Booting the emulator"
|
|
||||||
EMULATOR_FLAGS=(-avd "$AVD_NAME" -no-snapshot -wipe-data -no-audio -no-boot-anim -gpu swiftshader_indirect)
|
|
||||||
[ "${HEADLESS:-0}" = 1 ] && EMULATOR_FLAGS+=(-no-window)
|
|
||||||
"$EMULATOR" "${EMULATOR_FLAGS[@]}" >/tmp/noo_emulator.log 2>&1 &
|
|
||||||
EMULATOR_PID=$!
|
|
||||||
STARTED_EMULATOR=1
|
|
||||||
|
|
||||||
adb wait-for-device
|
|
||||||
for _ in $(seq 1 150); do
|
|
||||||
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] || die "the emulator did not finish booting (see /tmp/noo_emulator.log)"
|
|
||||||
SERIAL="$(adb devices | awk '/^emulator-/{print $1; exit}')"
|
|
||||||
fi
|
|
||||||
export ANDROID_SERIAL="$SERIAL"
|
|
||||||
|
|
||||||
log "Configuring the device"
|
|
||||||
adb shell input keyevent 82 >/dev/null 2>&1 || true # dismiss keyguard
|
|
||||||
adb shell svc power stayon true >/dev/null 2>&1 || true # never sleep mid-run
|
|
||||||
|
|
||||||
# Make the demo server reachable from the device as localhost. Binding a port
|
|
||||||
# below 1024 (the default 80) on the device needs root - the google_apis
|
|
||||||
# emulator images allow `adb root` - otherwise fall back to a high device
|
|
||||||
# port, which then shows up as ":8080" in the app.
|
|
||||||
DEVICE_PORT="$DEMO_PORT"
|
|
||||||
if [ "$DEMO_PORT" -lt 1024 ]; then
|
|
||||||
adb root >/dev/null 2>&1 || true
|
|
||||||
sleep 2
|
|
||||||
adb wait-for-device
|
|
||||||
fi
|
|
||||||
if ! adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" >/dev/null 2>&1; then
|
|
||||||
DEVICE_PORT=8080
|
|
||||||
printf 'note: could not bind port %s on the device (no root); using %s instead\n' "$DEMO_PORT" "$DEVICE_PORT" >&2
|
|
||||||
adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" || die "adb reverse failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
SERVER_HOST="localhost"
|
|
||||||
if [ -n "$DEMO_HOST" ]; then
|
|
||||||
# Best effort: make DEMO_HOST resolve to the (reverse-forwarded) loopback.
|
|
||||||
if adb root >/dev/null 2>&1 && sleep 2 && adb remount >/dev/null 2>&1 &&
|
|
||||||
adb shell "echo '127.0.0.1 $DEMO_HOST' >> /system/etc/hosts" >/dev/null 2>&1; then
|
|
||||||
SERVER_HOST="$DEMO_HOST"
|
|
||||||
else
|
|
||||||
printf 'warning: could not edit the device hosts file; showing "localhost" instead of %s\n' "$DEMO_HOST" >&2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
SERVER_URL="http://$SERVER_HOST"
|
|
||||||
[ "$DEVICE_PORT" = 80 ] || SERVER_URL="$SERVER_URL:$DEVICE_PORT"
|
|
||||||
|
|
||||||
# Clean status bar: 12:00, full battery, full Wi-Fi, no notification icons.
|
|
||||||
adb shell settings put global sysui_demo_allowed 1
|
|
||||||
demo() { adb shell am broadcast -a com.android.systemui.demo -e command "$@" >/dev/null; }
|
|
||||||
demo enter
|
|
||||||
demo clock -e hhmm 1200
|
|
||||||
demo battery -e level 100 -e plugged false
|
|
||||||
demo network -e wifi show -e level 4 -e fully true -e mobile hide
|
|
||||||
demo notifications -e visible false
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- capture ---
|
|
||||||
|
|
||||||
log "Capturing screenshots (this drives the app for a few minutes)"
|
|
||||||
rm -rf "$OUT_DIR"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
SCREENSHOT_DIR="$OUT_DIR" flutter drive \
|
|
||||||
--driver=test_driver/integration_test.dart \
|
|
||||||
--target=integration_test/store_screenshots_test.dart \
|
|
||||||
-d "$SERIAL" \
|
|
||||||
--dart-define="DEMO_SERVER_URL=$SERVER_URL" \
|
|
||||||
--dart-define="DEMO_USERNAME=Alex" \
|
|
||||||
--dart-define="DEMO_APP_PASSWORD=$APP_PASSWORD"
|
|
||||||
|
|
||||||
log "Making them Play Store ready"
|
|
||||||
"$PY" tool/finalize_screenshots.py "$OUT_DIR"
|
|
||||||
|
|
||||||
log "Done: $OUT_DIR"
|
|
||||||
printf '\nBefore uploading, LOOK at every image for anything real: the server\n' >&2
|
|
||||||
printf 'address/username in the header and Settings, notification icons, names.\n' >&2
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Uploads an .aab to the Play Console's app bundle library, without
|
|
||||||
touching any release track.
|
|
||||||
|
|
||||||
Play Console itself doesn't expose "just upload, don't release" as an
|
|
||||||
action - every path through the UI attaches a bundle to a track. The Play
|
|
||||||
Developer API does support it though: open an edit, upload the bundle,
|
|
||||||
commit the edit, and stop there. The result shows up under Release > App
|
|
||||||
bundle explorer and nowhere else - nothing goes out to testers or
|
|
||||||
production until it's promoted to a track by hand, whenever that's wanted.
|
|
||||||
|
|
||||||
Needs:
|
|
||||||
GOOGLE_PLAY_PACKAGE_NAME e.g. dev.ayushya.noo
|
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH path to a service account key file
|
|
||||||
|
|
||||||
The Play Developer API can't create an app's first release - Google
|
|
||||||
requires at least one manual upload through the Play Console web UI (to
|
|
||||||
any track) before it'll accept API calls for that app at all.
|
|
||||||
|
|
||||||
python3 tool/upload_play_bundle.py path/to/app-release.aab
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from google.oauth2 import service_account
|
|
||||||
from googleapiclient.discovery import build
|
|
||||||
|
|
||||||
SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]
|
|
||||||
|
|
||||||
|
|
||||||
def main(bundle_path):
|
|
||||||
package_name = os.environ["GOOGLE_PLAY_PACKAGE_NAME"]
|
|
||||||
key_path = os.environ["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"]
|
|
||||||
creds = service_account.Credentials.from_service_account_file(
|
|
||||||
key_path, scopes=SCOPES
|
|
||||||
)
|
|
||||||
service = build("androidpublisher", "v3", credentials=creds)
|
|
||||||
|
|
||||||
edit_id = service.edits().insert(body={}, packageName=package_name).execute()["id"]
|
|
||||||
|
|
||||||
bundle = (
|
|
||||||
service.edits()
|
|
||||||
.bundles()
|
|
||||||
.upload(editId=edit_id, packageName=package_name, media_body=bundle_path)
|
|
||||||
.execute()
|
|
||||||
)
|
|
||||||
print("Uploaded versionCode %s" % bundle["versionCode"])
|
|
||||||
|
|
||||||
service.edits().commit(editId=edit_id, packageName=package_name).execute()
|
|
||||||
print(
|
|
||||||
"Committed - visible under Release > App bundle explorer in Play "
|
|
||||||
"Console. Not attached to any track, so nothing goes out to "
|
|
||||||
"testers or production until it's promoted by hand."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main(sys.argv[1])
|
|
||||||
Reference in New Issue
Block a user