Compare commits
@@ -803,3 +803,11 @@ Flutter's own default to already be high enough.
|
||||
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.
|
||||
|
||||
@@ -137,10 +137,20 @@ existing split instead.
|
||||
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
|
||||
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
|
||||
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
|
||||
`internal` track as a **draft** (promote in Play Console, or edit
|
||||
`track`/`status` in the workflow). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
|
||||
`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/`.
|
||||
|
||||
@@ -29,6 +29,13 @@ widgets. Key points:
|
||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
|
||||
`surface` (cards, selection bar, rows) from the container roles so it
|
||||
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
|
||||
`surfaceContainer` in dark. The dynamic_color package seeds the container
|
||||
roles from the primary color rather than the OS palette, and in light mode
|
||||
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
|
||||
made cards blend into the page ("the selection bar has no background").
|
||||
- **Font**: Inter via `google_fonts`, applied through
|
||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||
@@ -135,6 +142,18 @@ blocks are noted where they matter:
|
||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||
Offline), and reused as-is by Favorites.
|
||||
- Hidden files and external storage are each a three-way segmented row
|
||||
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||
in both the Files and Photos filter sheets), not toggles:
|
||||
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
|
||||
cloud (default) / external / all. Files and Photos each persist their own
|
||||
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||
`StorageScope` stays shared. Leaving `hide` is still behind the
|
||||
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
|
||||
regular items first, then a collapsible "External storage" section
|
||||
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
|
||||
holding the external ones; Photos/Favorites just merge them into one list.
|
||||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||
trail Files uses in place of the shared
|
||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||
@@ -198,6 +217,27 @@ blocks are noted where they matter:
|
||||
bars and video transport controls (its other former user, the floating
|
||||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||
overlay instead of building a new blur/shadow combo.
|
||||
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
|
||||
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
|
||||
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
|
||||
expanded always labelled. Phone/Android layout only.
|
||||
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
|
||||
out past `viewport.height / doc.height`, so for short documents (<= 3
|
||||
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
|
||||
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
|
||||
the boundary height reaches the viewport and fit-width stays reachable
|
||||
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
|
||||
+ top bar height, 0 when the controls are hidden) animates the viewport
|
||||
below the overlaid top bar.
|
||||
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
|
||||
under the label, above the card; `SettingsSection` uses it for its
|
||||
`notice` on phones.
|
||||
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
|
||||
the media viewer's bottom bar as a draggable panel (mobile/phone only;
|
||||
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
|
||||
Details button, or swiping up on swipeable media (raw `Listener` in
|
||||
`file_viewer_screen.dart`) grows it upward so the action row rides on top
|
||||
of the details; tapping the media collapses it.
|
||||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||
@@ -210,7 +250,18 @@ blocks are noted where they matter:
|
||||
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
|
||||
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||
styles) swaps the bar's solid `surface` for a 0.72-alpha one over a
|
||||
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops
|
||||
`nooDialogShadow` (it would show through the glass). A frosted bar only
|
||||
reads as glass with content behind it, so the shell uses
|
||||
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
|
||||
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
|
||||
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
|
||||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||
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
|
||||
@@ -232,7 +283,11 @@ blocks are noted where they matter:
|
||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||
opens the account switcher. `showAvatarMenu` is this app's first use of
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -89,9 +89,9 @@ jobs:
|
||||
with:
|
||||
files: ${{ env.AAB_PATH }}
|
||||
|
||||
- name: Upload to Google Play (internal track, draft)
|
||||
# Stages the build only; promote it in Play Console, or change
|
||||
# `status` to "completed" (and/or `track`) to roll out automatically.
|
||||
- name: Upload to Google Play (internal track, auto-publish)
|
||||
# Rolls the build out to internal testers automatically
|
||||
# (`status: completed`); promote it to other tracks in Play Console.
|
||||
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
|
||||
# first release must already have been uploaded by hand.
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
@@ -101,5 +101,5 @@ jobs:
|
||||
releaseFiles: ${{ env.AAB_PATH }}
|
||||
releaseName: ${{ env.VERSION }}
|
||||
track: internal
|
||||
status: draft
|
||||
status: completed
|
||||
whatsNewDirectory: distribution/whatsnew
|
||||
|
||||
@@ -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`)
|
||||
@@ -45,3 +45,14 @@ app.*.map.json
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Local Claude Code state
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Signing / secrets (never commit)
|
||||
*.keystore
|
||||
*.jks
|
||||
key.properties
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
Nextcloud server — files, photos, shares, activity, and trash, all in one
|
||||
app, themed to match your device.
|
||||
Noo is a clean, modern way to browse and manage the files on your own
|
||||
Nextcloud server: files, photos, shares, activity, and trash in one app,
|
||||
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
|
||||
|
||||
- **Files** — browse, upload, move, rename, delete, and favorite files and
|
||||
folders; sort and filter per folder
|
||||
- **Photos** — a dedicated media gallery with a full-screen viewer for images
|
||||
and video
|
||||
- **Activity** — a feed of what's changed on your server
|
||||
- **Shares** — see and manage what you've shared and what's shared with you
|
||||
- **Trash** — restore or permanently delete recently removed items
|
||||
- **Material You theming** — dynamic color on supported devices, five
|
||||
built-in accent colors, light/dark/AMOLED
|
||||
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign
|
||||
in through your browser, and Noo never sees or stores your password,
|
||||
only a scoped app token
|
||||
|
||||
## Screenshots
|
||||
|
||||
_Coming soon._
|
||||
- **Files**: browse, upload, move, copy, rename, delete, and favorite files
|
||||
and folders; sort and filter per folder; list or grid view
|
||||
- **Photos**: a media gallery with a full-screen viewer for images and video
|
||||
- **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
|
||||
pull-up details panel (info, activity, sharing)
|
||||
- **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
|
||||
have shared, what changed, and what you deleted
|
||||
- **Search** across your server
|
||||
- **Offline and sync**: keep files available offline and sync chosen folders
|
||||
in the background
|
||||
- **Multiple accounts**: switch between saved accounts from the avatar menu
|
||||
- **App lock**: optionally require device authentication to open the app,
|
||||
switch accounts, or reveal hidden files
|
||||
- **Share to Noo**: upload from any app's share sheet
|
||||
- **Make it yours**: Material You dynamic color, five accent colors,
|
||||
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
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- [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
|
||||
Nextcloud 15)
|
||||
|
||||
@@ -39,38 +49,83 @@ flutter pub get
|
||||
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.
|
||||
|
||||
## Supported platforms
|
||||
|
||||
Android, iOS, and Windows.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
flutter build apk # Android
|
||||
flutter build apk # Android (debug-signed unless you configure a key)
|
||||
flutter build ios # iOS
|
||||
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
|
||||
|
||||
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
||||
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV
|
||||
and OCS APIs — no bundled Nextcloud SDK.
|
||||
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
|
||||
OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
|
||||
transfers and background sync.
|
||||
|
||||
## Contributing
|
||||
|
||||
Project context for contributors (human or AI) lives in
|
||||
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server
|
||||
integration, styling conventions, and code standards.
|
||||
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||
first. In short:
|
||||
|
||||
```bash
|
||||
flutter test # run tests
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
### Why PolyForm Shield?
|
||||
|
||||
I want the code to be open to read, learn from, build, and improve, while
|
||||
keeping anyone from lifting it and shipping a competing copy of the app.
|
||||
Permissive licenses (MIT, Apache) allow exactly that, and copyleft ones
|
||||
(GPL) still allow a rebranded fork. PolyForm Shield sits in between: it is
|
||||
not an OSI "open source" license, and I'm not claiming it is, but it keeps
|
||||
the code available while protecting the project.
|
||||
|
||||
## Where development happens
|
||||
|
||||
The source of truth is a self-hosted Gitea instance, and this GitHub
|
||||
repository is a read-only mirror plus the public issue tracker. I keep it
|
||||
that way so that I own my hosting, builds, and release credentials rather
|
||||
than depending on a third party for them. You don't have to use Gitea:
|
||||
open issues and pull requests here as usual. Open PRs are imported to my
|
||||
instance, reviewed and merged there, and `main` is mirrored back here, so a
|
||||
merged PR may show as closed with a link to the commit instead of "Merged".
|
||||
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details.
|
||||
|
||||
@@ -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.
|
||||
@@ -360,7 +360,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -486,7 +486,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -537,7 +537,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 19 KiB |
@@ -36,7 +36,7 @@ import 'widgets/create_menu.dart';
|
||||
import 'widgets/noo/core/noo_avatar.dart';
|
||||
import 'widgets/noo/core/noo_button.dart';
|
||||
import 'widgets/noo/core/noo_fab.dart';
|
||||
import 'widgets/noo/nav/noo_nav_style.dart';
|
||||
import 'widgets/noo/nav/noo_bottom_bar.dart';
|
||||
import 'widgets/noo/nav/noo_sidebar.dart';
|
||||
import 'widgets/noo/nav/noo_toolbar.dart';
|
||||
import 'widgets/noo/noo_layout.dart';
|
||||
@@ -520,10 +520,18 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
drawerScrimColor: colors.scrim,
|
||||
// The top bar itself no longer lives here - see `tabStack` above.
|
||||
drawer: pickRequest == null ? const AppDrawer() : null,
|
||||
// The avatar menu replaces the drawer, so the edge swipe must not
|
||||
// open it either.
|
||||
drawerEnableOpenDragGesture:
|
||||
settings.navMenuStyle == NooNavMenuStyle.drawer,
|
||||
// Floating needs the body to draw behind the bar's own transparent
|
||||
// margin (see NooBottomBarStyle's doc comment) instead of stopping
|
||||
// short of it like attached does.
|
||||
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
|
||||
// short of it like attached does; a frosted bar needs it too, or
|
||||
// there'd be nothing behind it to blur.
|
||||
extendBody: NooBottomBar.drawsBehindBody(
|
||||
bottomBarStyle,
|
||||
settings.bottomBarFrosted,
|
||||
),
|
||||
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
|
||||
// instead (see AppTopBar). Stays mounted across every tab (picking
|
||||
// aside) and collapses to an icon-only circle off Files/Photos,
|
||||
@@ -532,7 +540,11 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
floatingActionButton:
|
||||
pickRequest == null && navStyle == NooNavStyle.android
|
||||
? NooFab(
|
||||
collapsed: !canUpload,
|
||||
collapsed: switch (settings.fabStyle) {
|
||||
FabStyle.auto => !canUpload,
|
||||
FabStyle.mini => true,
|
||||
FabStyle.expanded => false,
|
||||
},
|
||||
barStyle: bottomBarStyle,
|
||||
onTap: () => showCreateMenu(context),
|
||||
)
|
||||
@@ -541,6 +553,7 @@ class _MainShellViewState extends State<MainShellView> {
|
||||
bottomNavigationBar: BottomNavBar(
|
||||
style: navStyle,
|
||||
barStyle: bottomBarStyle,
|
||||
frosted: settings.bottomBarFrosted,
|
||||
tabs: pinnedTabs,
|
||||
selectedIndex: pinnedIndex,
|
||||
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
|
||||
|
||||
@@ -39,4 +39,20 @@ class NextcloudShare {
|
||||
});
|
||||
|
||||
bool get isFolder => itemType == NextcloudItemType.folder;
|
||||
|
||||
NextcloudShare withPermissions(int permissions) => NextcloudShare(
|
||||
id: id,
|
||||
path: path,
|
||||
name: name,
|
||||
itemType: itemType,
|
||||
shareType: shareType,
|
||||
ownerDisplayName: ownerDisplayName,
|
||||
sharedWithDisplayName: sharedWithDisplayName,
|
||||
sharedAt: sharedAt,
|
||||
sharedWithMe: sharedWithMe,
|
||||
url: url,
|
||||
permissions: permissions,
|
||||
token: token,
|
||||
expireDate: expireDate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ import 'session_controller.dart';
|
||||
|
||||
enum FileSortField { name, dateCreated, dateModified, size }
|
||||
|
||||
/// Which storage a listing shows — always exactly one, like the list/grid
|
||||
/// view toggle, not an optional filter. Shared across Files/Photos/
|
||||
/// Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external }
|
||||
/// Which storage a listing shows: [cloud] (the default - external mounts
|
||||
/// hidden), only [external] ones, or [all] of both (Files shows the
|
||||
/// external items in their own collapsible section then). Shared across
|
||||
/// Files/Photos/Favorites (see [applyCommonFilters]'s doc comment).
|
||||
enum StorageScope { cloud, external, all }
|
||||
|
||||
/// Which dotfile/dotfolder items a listing shows: [hide] them (the
|
||||
/// default), show [only] them, or [include] them alongside everything else.
|
||||
/// Files and Photos each keep their own (see [applyCommonFilters]).
|
||||
enum HiddenFilesFilter { hide, only, include }
|
||||
|
||||
/// The Files tab's files/folders/both filter — independent of and applied
|
||||
/// after [StorageScope]/favorites/hidden filtering.
|
||||
@@ -72,6 +78,7 @@ class FilesController extends ChangeNotifier
|
||||
static const _prefStorageScope = 'ui_storage_scope';
|
||||
static const _prefFilesTypeFilter = 'ui_files_type_filter';
|
||||
static const _prefShowHiddenFiles = 'ui_show_hidden';
|
||||
static const _prefHiddenFilter = 'ui_hidden_filter';
|
||||
static const _prefFolderSort = 'ui_folder_sort';
|
||||
static const prefCachePolicy = 'ui_cache_policy';
|
||||
static const prefCacheIntervalMinutes = 'ui_cache_interval_minutes';
|
||||
@@ -82,7 +89,7 @@ class FilesController extends ChangeNotifier
|
||||
bool _isGridView = false;
|
||||
StorageScope _storageScope = StorageScope.cloud;
|
||||
FilesTypeFilter _filesTypeFilter = FilesTypeFilter.all;
|
||||
bool _showHiddenFiles = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
|
||||
final Map<String, FileSortField> _folderSortField = {};
|
||||
final Map<String, bool> _folderSortAscending = {};
|
||||
@@ -101,6 +108,7 @@ class FilesController extends ChangeNotifier
|
||||
NextcloudUserQuota? _quota;
|
||||
List<NextcloudActivity> _activities = [];
|
||||
bool _isLoading = false;
|
||||
bool _activating = false;
|
||||
String? _errorMessage;
|
||||
|
||||
FilesController(this.session) {
|
||||
@@ -134,7 +142,7 @@ class FilesController extends ChangeNotifier
|
||||
bool get isGridView => _isGridView;
|
||||
StorageScope get storageScope => _storageScope;
|
||||
FilesTypeFilter get filesTypeFilter => _filesTypeFilter;
|
||||
bool get showHiddenFiles => _showHiddenFiles;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get filesSortField => sortFieldFor(_currentFolderPath);
|
||||
bool get filesSortAscending => sortAscendingFor(_currentFolderPath);
|
||||
|
||||
@@ -147,7 +155,10 @@ class FilesController extends ChangeNotifier
|
||||
CachePolicy get cachePolicy => _cachePolicy;
|
||||
int get cacheIntervalMinutes => _cacheIntervalMinutes;
|
||||
@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
|
||||
String? get errorMessage => _errorMessage;
|
||||
NextcloudUserQuota? get quota => _quota;
|
||||
@@ -184,24 +195,30 @@ class FilesController extends ChangeNotifier
|
||||
List<NextcloudItem> applyCommonFilters(
|
||||
List<NextcloudItem> source, {
|
||||
required bool showFavoritesOnly,
|
||||
required bool showHidden,
|
||||
required HiddenFilesFilter hidden,
|
||||
bool applyStorageScope = true,
|
||||
}) {
|
||||
var filtered = source;
|
||||
if (showFavoritesOnly) {
|
||||
filtered = filtered.where((item) => item.isFavorite).toList();
|
||||
}
|
||||
if (!showHidden) {
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
switch (hidden) {
|
||||
case HiddenFilesFilter.hide:
|
||||
filtered = filtered.where((item) => !_isHiddenItem(item)).toList();
|
||||
case HiddenFilesFilter.only:
|
||||
filtered = filtered.where(_isHiddenItem).toList();
|
||||
case HiddenFilesFilter.include:
|
||||
break;
|
||||
}
|
||||
if (applyStorageScope) {
|
||||
filtered = filtered
|
||||
.where(
|
||||
(item) => _storageScope == StorageScope.external
|
||||
? item.isExternalStorage
|
||||
: !item.isExternalStorage,
|
||||
)
|
||||
.toList();
|
||||
switch (_storageScope) {
|
||||
case StorageScope.cloud:
|
||||
filtered = filtered.where((i) => !i.isExternalStorage).toList();
|
||||
case StorageScope.external:
|
||||
filtered = filtered.where((i) => i.isExternalStorage).toList();
|
||||
case StorageScope.all:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -227,7 +244,7 @@ class FilesController extends ChangeNotifier
|
||||
// Files itself doesn't filter by favorite - that's the dedicated
|
||||
// Favorites tab's job.
|
||||
showFavoritesOnly: false,
|
||||
showHidden: _showHiddenFiles,
|
||||
hidden: _hiddenFilter,
|
||||
applyStorageScope: applyStorageScope,
|
||||
);
|
||||
switch (_filesTypeFilter) {
|
||||
@@ -266,7 +283,7 @@ class FilesController extends ChangeNotifier
|
||||
_isGridView = false;
|
||||
_storageScope = StorageScope.cloud;
|
||||
_filesTypeFilter = FilesTypeFilter.all;
|
||||
_showHiddenFiles = false;
|
||||
_hiddenFilter = HiddenFilesFilter.hide;
|
||||
_cachePolicy = defaultCachePolicy;
|
||||
_cacheIntervalMinutes = defaultCacheIntervalMinutes;
|
||||
_isLoading = false;
|
||||
@@ -317,7 +334,11 @@ class FilesController extends ChangeNotifier
|
||||
(f) => f.name == filesTypeFilterName,
|
||||
orElse: () => FilesTypeFilter.all,
|
||||
);
|
||||
_showHiddenFiles = prefs.getBool(k(_prefShowHiddenFiles)) ?? false;
|
||||
_hiddenFilter = loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilter),
|
||||
k(_prefShowHiddenFiles),
|
||||
);
|
||||
|
||||
final folderSortJson = prefs.getString(k(_prefFolderSort));
|
||||
if (folderSortJson != null) {
|
||||
@@ -360,9 +381,16 @@ class FilesController extends ChangeNotifier
|
||||
}
|
||||
|
||||
Future<void> _onAccountActivated() async {
|
||||
await _restoreDisplayPrefs();
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
await refreshData();
|
||||
_activating = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _restoreDisplayPrefs();
|
||||
_startCacheRefreshTimerIfNeeded();
|
||||
await refreshData();
|
||||
} finally {
|
||||
_activating = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -628,10 +656,12 @@ class FilesController extends ChangeNotifier
|
||||
);
|
||||
}
|
||||
|
||||
/// Only *enabling* hidden-files visibility is gated - hiding them again
|
||||
/// never exposes anything, so that direction is always allowed instantly.
|
||||
Future<void> toggleShowHiddenFiles() async {
|
||||
if (!_showHiddenFiles) {
|
||||
/// Only *leaving* [HiddenFilesFilter.hide] is gated - going back to it
|
||||
/// never exposes anything, so that direction is always allowed instantly
|
||||
/// (and switching between the two revealing modes needs no second prompt).
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -639,14 +669,35 @@ class FilesController extends ChangeNotifier
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHiddenFiles = !_showHiddenFiles;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenFiles,
|
||||
(p, key) => p.setBool(key, _showHiddenFiles),
|
||||
_prefHiddenFilter,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// Reads a persisted [HiddenFilesFilter], falling back to the old
|
||||
/// show-hidden bool (`true` meant everything incl. hidden) so a pref
|
||||
/// saved before the three-way filter keeps its meaning. [key]s are
|
||||
/// already account-namespaced by the caller.
|
||||
static HiddenFilesFilter loadHiddenFilter(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
String legacyBoolKey,
|
||||
) {
|
||||
final name = prefs.getString(key);
|
||||
if (name != null) {
|
||||
return HiddenFilesFilter.values.firstWhere(
|
||||
(f) => f.name == name,
|
||||
orElse: () => HiddenFilesFilter.hide,
|
||||
);
|
||||
}
|
||||
return (prefs.getBool(legacyBoolKey) ?? false)
|
||||
? HiddenFilesFilter.include
|
||||
: HiddenFilesFilter.hide;
|
||||
}
|
||||
|
||||
void _persistFolderSort() {
|
||||
final combined = <String, dynamic>{};
|
||||
for (final path in {
|
||||
|
||||
@@ -168,9 +168,9 @@ class ItemOperations {
|
||||
(c) => choices[c.item.id] == ConflictChoice.keepBoth,
|
||||
);
|
||||
final existingNames = needsKeepBoth
|
||||
? (await files.fetchFolderListing(destFolderPath))
|
||||
.map((i) => i.name)
|
||||
.toSet()
|
||||
? (await files.fetchFolderListing(
|
||||
destFolderPath,
|
||||
)).map((i) => i.name).toSet()
|
||||
: <String>{};
|
||||
|
||||
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
|
||||
/// tab itself - see `SharesController.deleteShare` for that one, which
|
||||
/// additionally patches the tab's own list).
|
||||
|
||||
@@ -17,6 +17,7 @@ class PhotosController extends ChangeNotifier {
|
||||
|
||||
static const _prefShowFavoritesOnlyPhotos = 'ui_show_favorites_only_photos';
|
||||
static const _prefShowHiddenPhotos = 'ui_show_hidden_photos';
|
||||
static const _prefHiddenFilterPhotos = 'ui_hidden_filter_photos';
|
||||
static const _prefSortField = 'ui_sort_field';
|
||||
static const _prefSortAscending = 'ui_sort_ascending';
|
||||
|
||||
@@ -25,7 +26,7 @@ class PhotosController extends ChangeNotifier {
|
||||
String? _errorMessage;
|
||||
|
||||
bool _showFavoritesOnly = false;
|
||||
bool _showHidden = false;
|
||||
HiddenFilesFilter _hiddenFilter = HiddenFilesFilter.hide;
|
||||
FileSortField _sortField = FileSortField.name;
|
||||
bool _sortAscending = true;
|
||||
|
||||
@@ -37,7 +38,7 @@ class PhotosController extends ChangeNotifier {
|
||||
bool get isLoading => _isLoading;
|
||||
String? get errorMessage => _errorMessage;
|
||||
bool get showFavoritesOnly => _showFavoritesOnly;
|
||||
bool get showHidden => _showHidden;
|
||||
HiddenFilesFilter get hiddenFilter => _hiddenFilter;
|
||||
FileSortField get sortField => _sortField;
|
||||
bool get sortAscending => _sortAscending;
|
||||
|
||||
@@ -48,7 +49,7 @@ class PhotosController extends ChangeNotifier {
|
||||
final filtered = files.applyCommonFilters(
|
||||
media,
|
||||
showFavoritesOnly: _showFavoritesOnly,
|
||||
showHidden: _showHidden,
|
||||
hidden: _hiddenFilter,
|
||||
)..sort((a, b) => _compare(a, b));
|
||||
return _sortAscending ? filtered : filtered.reversed.toList();
|
||||
}
|
||||
@@ -86,7 +87,11 @@ class PhotosController extends ChangeNotifier {
|
||||
String k(String base) => session.accountStore.accountPrefKey(id, base);
|
||||
_showFavoritesOnly =
|
||||
prefs.getBool(k(_prefShowFavoritesOnlyPhotos)) ?? false;
|
||||
_showHidden = prefs.getBool(k(_prefShowHiddenPhotos)) ?? false;
|
||||
_hiddenFilter = FilesController.loadHiddenFilter(
|
||||
prefs,
|
||||
k(_prefHiddenFilterPhotos),
|
||||
k(_prefShowHiddenPhotos),
|
||||
);
|
||||
final sortFieldName = prefs.getString(k(_prefSortField));
|
||||
_sortField = FileSortField.values.firstWhere(
|
||||
(f) => f.name == sortFieldName,
|
||||
@@ -148,8 +153,10 @@ class PhotosController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleShowHidden() async {
|
||||
if (!_showHidden) {
|
||||
/// Gated the same way as [FilesController.setHiddenFilter].
|
||||
Future<void> setHiddenFilter(HiddenFilesFilter filter) async {
|
||||
if (_hiddenFilter == filter) return;
|
||||
if (_hiddenFilter == HiddenFilesFilter.hide) {
|
||||
if (!await session.passGate(
|
||||
session.lockHiddenFiles,
|
||||
'Unlock to show hidden files',
|
||||
@@ -157,11 +164,11 @@ class PhotosController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_showHidden = !_showHidden;
|
||||
_hiddenFilter = filter;
|
||||
notifyListeners();
|
||||
_persistAccountPref(
|
||||
_prefShowHiddenPhotos,
|
||||
(p, key) => p.setBool(key, _showHidden),
|
||||
_prefHiddenFilterPhotos,
|
||||
(p, key) => p.setString(key, filter.name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ import '../widgets/noo/nav/noo_nav_style.dart';
|
||||
/// in Settings.
|
||||
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
|
||||
/// combinations offered by other Material You media players: a Material 3
|
||||
/// slider-style thumb ([classic]), an animated travelling wave with a round
|
||||
@@ -31,7 +36,9 @@ class SettingsController extends ChangeNotifier {
|
||||
static const _prefDefaultTab = 'ui_default_tab';
|
||||
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
|
||||
static const _prefSwipeRightAction = 'ui_swipe_right_action';
|
||||
static const _prefFabStyle = 'ui_fab_style';
|
||||
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
|
||||
static const _prefBottomBarFrosted = 'ui_bottom_bar_frosted';
|
||||
static const _prefNavMenuStyle = 'ui_nav_menu_style';
|
||||
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
|
||||
static const _prefAmoledDark = 'ui_amoled_dark';
|
||||
@@ -45,6 +52,7 @@ class SettingsController extends ChangeNotifier {
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
|
||||
NooNavMenuStyle _navMenuStyle = NooNavMenuStyle.drawer;
|
||||
bool _bottomBarFrosted = false;
|
||||
bool _searchInBottomBar = false;
|
||||
bool _useDynamicColor = true;
|
||||
bool _amoledDark = false;
|
||||
@@ -61,6 +69,7 @@ class SettingsController extends ChangeNotifier {
|
||||
|
||||
SwipeAction _swipeLeftAction = SwipeAction.delete;
|
||||
SwipeAction _swipeRightAction = SwipeAction.favorite;
|
||||
FabStyle _fabStyle = FabStyle.auto;
|
||||
|
||||
// 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
|
||||
@@ -88,6 +97,7 @@ class SettingsController extends ChangeNotifier {
|
||||
Color get seedColor => _seedColor;
|
||||
ThemeMode get themeMode => _themeMode;
|
||||
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
|
||||
bool get bottomBarFrosted => _bottomBarFrosted;
|
||||
NooNavMenuStyle get navMenuStyle => _navMenuStyle;
|
||||
bool get searchInBottomBar => _searchInBottomBar;
|
||||
bool get useDynamicColor => _useDynamicColor;
|
||||
@@ -121,6 +131,7 @@ class SettingsController extends ChangeNotifier {
|
||||
|
||||
SwipeAction get swipeLeftAction => _swipeLeftAction;
|
||||
SwipeAction get swipeRightAction => _swipeRightAction;
|
||||
FabStyle get fabStyle => _fabStyle;
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
@@ -147,6 +158,8 @@ class SettingsController extends ChangeNotifier {
|
||||
orElse: () => _navMenuStyle,
|
||||
);
|
||||
}
|
||||
_bottomBarFrosted =
|
||||
prefs.getBool(_prefBottomBarFrosted) ?? _bottomBarFrosted;
|
||||
_searchInBottomBar =
|
||||
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
|
||||
_useDynamicColor =
|
||||
@@ -242,6 +255,14 @@ class SettingsController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
final fabName = prefs.getString(_prefFabStyle);
|
||||
if (fabName != null) {
|
||||
_fabStyle = FabStyle.values.firstWhere(
|
||||
(f) => f.name == fabName,
|
||||
orElse: () => _fabStyle,
|
||||
);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('[SettingsController] Preference restore failed: $e');
|
||||
@@ -297,6 +318,13 @@ class SettingsController extends ChangeNotifier {
|
||||
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
|
||||
}
|
||||
|
||||
void setBottomBarFrosted(bool value) {
|
||||
if (_bottomBarFrosted == value) return;
|
||||
_bottomBarFrosted = value;
|
||||
notifyListeners();
|
||||
_prefsFuture.then((p) => p.setBool(_prefBottomBarFrosted, value));
|
||||
}
|
||||
|
||||
void setNavMenuStyle(NooNavMenuStyle style) {
|
||||
if (_navMenuStyle == style) return;
|
||||
_navMenuStyle = style;
|
||||
@@ -404,4 +432,11 @@ class SettingsController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
_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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1538,6 +1538,22 @@ class NextcloudService {
|
||||
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}) {
|
||||
final path = (raw['path'] ?? '/').toString();
|
||||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||||
|
||||
@@ -139,11 +139,20 @@ class NooColors extends ThemeExtension<NooColors> {
|
||||
/// paints it, not painted directly - using it as-is for a sheet/dialog's
|
||||
/// `barrierColor` hid the screen behind completely instead of dimming it,
|
||||
/// so this uses the fixed palette's already-translucent scrim instead.
|
||||
///
|
||||
/// `surface` (cards, bars, rows) has to read as lighter than `bg`, like
|
||||
/// the fixed palettes' white-on-beige / lifted-on-near-black. In light
|
||||
/// mode `surfaceContainerLow` is a hair *darker* than the OS-provided
|
||||
/// `surface` that [bg] uses, so cards (the selection bar, list rows)
|
||||
/// vanished into the page; `surfaceContainerLowest` (white) doesn't. Dark
|
||||
/// mode uses `surfaceContainer` rather than `...Low` for a bigger step
|
||||
/// off the page.
|
||||
factory NooColors.fromDynamicScheme(ColorScheme cs) {
|
||||
final fixed = cs.brightness == Brightness.dark ? dark : light;
|
||||
final dark = cs.brightness == Brightness.dark;
|
||||
final fixed = dark ? NooColors.dark : NooColors.light;
|
||||
return NooColors(
|
||||
bg: cs.surface,
|
||||
surface: cs.surfaceContainerLow,
|
||||
surface: dark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
surface2: cs.surfaceContainerHigh,
|
||||
surface3: cs.surfaceContainerHighest,
|
||||
line: cs.outlineVariant,
|
||||
|
||||
@@ -21,6 +21,7 @@ import '../widgets/noo/overlays/noo_overlay_header.dart';
|
||||
import '../widgets/noo/overlays/noo_sheet.dart';
|
||||
import '../widgets/share_sheet.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_pdf_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.
|
||||
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];
|
||||
|
||||
bool get _isOffline => widget.localPathResolver != null;
|
||||
@@ -394,13 +432,25 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
backgroundColor: stageColor,
|
||||
body: GestureDetector(
|
||||
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(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: stageColor,
|
||||
child: _buildBody(context, session),
|
||||
child: Listener(
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerUp: _onPointerUp,
|
||||
onPointerCancel: _onPointerCancel,
|
||||
child: Container(
|
||||
color: stageColor,
|
||||
child: _buildBody(context, session),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_downloadProgress != null)
|
||||
@@ -522,25 +572,35 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
|
||||
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
|
||||
child: IgnorePointer(
|
||||
ignoring: !_controlsVisible,
|
||||
child: MediaActionBar(
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
// Favorite/delete/download-to-device all need a live
|
||||
// server - hidden rather than shown-and-failing while
|
||||
// browsing an already-local file from the Offline tab.
|
||||
showServerActions: !_isOffline,
|
||||
transportControls:
|
||||
_isSwipeable &&
|
||||
_currentItem.type == NextcloudItemType.video &&
|
||||
_videoController != null
|
||||
? VideoTransportControls(controller: _videoController!)
|
||||
: null,
|
||||
onShare: () => ShareSheet.show(context, _currentItem),
|
||||
onFavorite: () => _toggleFavorite(ops),
|
||||
onDelete: () => _deleteCurrentItem(ops),
|
||||
onOpenExternally: () => _openExternally(session),
|
||||
onDownload: () => _downloadToDevice(session),
|
||||
onDetails: () => DetailsSheet.show(context, _currentItem),
|
||||
child: MediaDetailsPanel(
|
||||
key: _panelKey,
|
||||
item: _currentItem,
|
||||
builder: (context, handle, below) => MediaActionBar(
|
||||
onDetails: NooLayout.isDesktop(context)
|
||||
? _openDetails
|
||||
: null,
|
||||
handle: NooLayout.isDesktop(context) ? null : handle,
|
||||
below: NooLayout.isDesktop(context) ? null : below,
|
||||
isFavorite: _currentItem.isFavorite,
|
||||
isBusy: _isBusy,
|
||||
// Favorite/delete/download-to-device all need a live
|
||||
// server - hidden rather than shown-and-failing while
|
||||
// browsing an already-local file from the Offline tab.
|
||||
showServerActions: !_isOffline,
|
||||
transportControls:
|
||||
_isSwipeable &&
|
||||
_currentItem.type == NextcloudItemType.video &&
|
||||
_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:
|
||||
if (_isPdf) {
|
||||
return MediaPdfPreview(
|
||||
topInset: _controlsVisible
|
||||
? MediaQuery.paddingOf(context).top + 60
|
||||
: 0,
|
||||
item: widget.item,
|
||||
session: session,
|
||||
localPathResolver: widget.localPathResolver,
|
||||
|
||||
@@ -76,6 +76,57 @@ class FilesView extends StatefulWidget {
|
||||
State<FilesView> createState() => _FilesViewState();
|
||||
}
|
||||
|
||||
/// The collapsible "External storage" header [StorageScope.all] puts above
|
||||
/// the external items.
|
||||
class _ExternalStorageHeader extends StatelessWidget {
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ExternalStorageHeader({
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Semantics(
|
||||
button: true,
|
||||
expanded: expanded,
|
||||
label: 'External storage',
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: NooSpace.md,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.hardDrive, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'External storage',
|
||||
style: NooText.bodyL.copyWith(color: colors.fg1),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('$count', style: NooText.body.copyWith(color: colors.fg3)),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
expanded ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
size: 18,
|
||||
color: colors.fg3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slides+fades its child in on first build. Give it a [Key] that changes
|
||||
/// whenever the folder changes (folder path + item id) so Flutter discards
|
||||
/// and remounts the Element instead of just updating it in place - that's
|
||||
@@ -236,6 +287,9 @@ class _GridThumbnail extends StatelessWidget {
|
||||
|
||||
class _FilesViewState extends State<FilesView> {
|
||||
final Set<String> _selectedIds = {};
|
||||
|
||||
/// Whether [StorageScope.all]'s external-storage section is open.
|
||||
bool _externalExpanded = true;
|
||||
int _lastPathDepth = 1;
|
||||
|
||||
bool get _isSelecting => _selectedIds.isNotEmpty;
|
||||
@@ -245,9 +299,19 @@ class _FilesViewState extends State<FilesView> {
|
||||
/// The folder data source for this tab. Watches the concrete controller
|
||||
/// (that's what's registered as a provider), then hands it back as the
|
||||
/// [FolderBrowser] the rest of the view actually needs.
|
||||
FolderBrowser _browserOf(BuildContext context) => _offline
|
||||
? context.watch<OfflineController>()
|
||||
: context.watch<FilesController>();
|
||||
///
|
||||
/// Pass `listen: false` from tap/gesture callbacks: `context.watch` outside
|
||||
/// of `build` asserts in debug builds (silently swallowing the tap).
|
||||
FolderBrowser _browserOf(BuildContext context, {bool listen = true}) {
|
||||
if (listen) {
|
||||
return _offline
|
||||
? context.watch<OfflineController>()
|
||||
: context.watch<FilesController>();
|
||||
}
|
||||
return _offline
|
||||
? context.read<OfflineController>()
|
||||
: context.read<FilesController>();
|
||||
}
|
||||
|
||||
/// The on-device copy of [item] for the Offline tab; null online, where
|
||||
/// thumbnails come from server previews instead.
|
||||
@@ -287,7 +351,7 @@ class _FilesViewState extends State<FilesView> {
|
||||
NextcloudItem item, {
|
||||
required bool picking,
|
||||
}) {
|
||||
final browser = _browserOf(context);
|
||||
final browser = _browserOf(context, listen: false);
|
||||
if (picking) {
|
||||
_handlePickTap(context, item);
|
||||
} else if (_isSelecting) {
|
||||
@@ -659,6 +723,131 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
);
|
||||
|
||||
// One sliver group per layout (grid / desktop table / mobile list) for
|
||||
// an arbitrary slice of items - [StorageScope.all] renders two of them,
|
||||
// the regular items and then the external ones under a collapsible
|
||||
// header, instead of one list over `browser.items`.
|
||||
List<Widget> itemSlivers(
|
||||
List<NextcloudItem> items, {
|
||||
required String section,
|
||||
bool tableHeader = true,
|
||||
}) {
|
||||
if (files.isGridView) {
|
||||
return [
|
||||
SliverPadding(
|
||||
key: ValueKey('files-grid-$section'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isDesktop) {
|
||||
return [
|
||||
if (tableHeader)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: ValueKey('files-table-$section'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
SliverPadding(
|
||||
key: ValueKey('files-list-$section'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(context, item, index, items.length),
|
||||
);
|
||||
}, childCount: items.length),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// "All + external": external items leave the main list for their own
|
||||
// collapsible section below it. Offline has no such distinction.
|
||||
final splitExternal = !_offline && files.storageScope == StorageScope.all;
|
||||
final mainItems = splitExternal
|
||||
? browser.items.where((i) => !i.isExternalStorage).toList()
|
||||
: browser.items;
|
||||
final externalItems = splitExternal
|
||||
? browser.items.where((i) => i.isExternalStorage).toList()
|
||||
: const <NextcloudItem>[];
|
||||
final bodySlivers = <Widget>[
|
||||
if (mainItems.isNotEmpty) ...itemSlivers(mainItems, section: 'main'),
|
||||
if (externalItems.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: _ExternalStorageHeader(
|
||||
count: externalItems.length,
|
||||
expanded: _externalExpanded,
|
||||
onTap: () =>
|
||||
setState(() => _externalExpanded = !_externalExpanded),
|
||||
),
|
||||
),
|
||||
if (_externalExpanded)
|
||||
...itemSlivers(
|
||||
externalItems,
|
||||
section: 'external',
|
||||
tableHeader: mainItems.isEmpty,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final List<Widget> contentSlivers = [
|
||||
if (widget.topBar != null) topBarSliver(widget.topBar!),
|
||||
// Pinned in both states - while browsing this is the controls row
|
||||
@@ -743,94 +932,17 @@ class _FilesViewState extends State<FilesView> {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (files.isGridView)
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-grid'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
gutter,
|
||||
NooSpace.xs,
|
||||
gutter,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: isDesktop
|
||||
? 5
|
||||
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
|
||||
childAspectRatio: isDesktop ? 1.05 : 0.92,
|
||||
crossAxisSpacing: isDesktop ? 16 : 10,
|
||||
mainAxisSpacing: isDesktop ? 16 : 10,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildGridCard(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
)
|
||||
else if (isDesktop) ...[
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table-header'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, NooSpace.xs, gutter, 0),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: _buildDesktopHeader(
|
||||
context,
|
||||
files,
|
||||
browser.currentFolderPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-table'),
|
||||
padding: EdgeInsets.fromLTRB(gutter, 0, gutter, NooSpace.lg),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildDesktopRow(context, item),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
] else
|
||||
SliverPadding(
|
||||
key: const ValueKey('files-list'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
NooSpace.sm,
|
||||
NooSpace.xs,
|
||||
NooSpace.sm,
|
||||
NooSpace.lg,
|
||||
),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final item = browser.items[index];
|
||||
return _FolderEnterAnimation(
|
||||
key: ValueKey('${browser.currentFolderPath}::${item.id}'),
|
||||
index: index,
|
||||
fromRight: navigatingDeeper,
|
||||
child: _buildMobileRow(
|
||||
context,
|
||||
item,
|
||||
index,
|
||||
browser.items.length,
|
||||
),
|
||||
);
|
||||
}, childCount: browser.items.length),
|
||||
),
|
||||
),
|
||||
else
|
||||
...bodySlivers,
|
||||
// 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)
|
||||
// draws the body behind the bar instead (as does a frosted bar), so it
|
||||
// needs the bar's own footprint added on top or the last row ends up
|
||||
// under it.
|
||||
if (NooBottomBar.drawsBehindBody(
|
||||
context.watch<SettingsController>().bottomBarStyle,
|
||||
context.watch<SettingsController>().bottomBarFrosted,
|
||||
))
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: bottomBarClearance(context)),
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../providers/settings_controller.dart';
|
||||
import '../services/download_service.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import '../widgets/details/details_sheet.dart';
|
||||
import '../widgets/filter_mode_rows.dart';
|
||||
import '../widgets/noo/core/noo_button.dart';
|
||||
import '../widgets/noo/core/noo_chip.dart';
|
||||
import '../widgets/noo/core/noo_segmented_control.dart';
|
||||
@@ -404,7 +405,7 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
final filtersActive =
|
||||
_typeFilter != _PhotoTypeFilter.all ||
|
||||
photos.showFavoritesOnly ||
|
||||
photos.showHidden ||
|
||||
photos.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
files.storageScope != StorageScope.cloud;
|
||||
|
||||
// A plain Row, not a horizontally-scrolling one - see
|
||||
@@ -521,32 +522,36 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
// Same "icon always, label only when selected" pill as
|
||||
// Files' own type filter - not a fully-labelled track, so
|
||||
// the two screens' filter sheets look and behave the same.
|
||||
NooSegmentedControl<_PhotoTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: _typeFilter,
|
||||
onChanged: (filter) => setState(() {
|
||||
_typeFilter = filter;
|
||||
setSheetState(() {});
|
||||
}),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.image,
|
||||
icon: LucideIcons.image,
|
||||
label: 'Photos',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.video,
|
||||
icon: LucideIcons.film,
|
||||
label: 'Videos',
|
||||
),
|
||||
],
|
||||
FilterSection(
|
||||
icon: LucideIcons.layoutGrid,
|
||||
title: 'Type',
|
||||
child: NooSegmentedControl<_PhotoTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: _typeFilter,
|
||||
onChanged: (filter) => setState(() {
|
||||
_typeFilter = filter;
|
||||
setSheetState(() {});
|
||||
}),
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.image,
|
||||
icon: LucideIcons.image,
|
||||
label: 'Photos',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: _PhotoTypeFilter.video,
|
||||
icon: LucideIcons.film,
|
||||
label: 'Videos',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
@@ -559,26 +564,18 @@ class _PhotosViewState extends State<PhotosView> {
|
||||
onChanged: (_) => photos.toggleFavoritesFilter(),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: photos.showHidden,
|
||||
onChanged: (_) => photos.toggleShowHidden(),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
HiddenFilesFilterRow(
|
||||
value: photos.hiddenFilter,
|
||||
onChanged: photos.setHiddenFilter,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -43,9 +44,16 @@ Future<void> showAvatarMenu(BuildContext context) {
|
||||
);
|
||||
}
|
||||
|
||||
class _AvatarMenuContent extends StatelessWidget {
|
||||
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;
|
||||
@@ -124,7 +132,7 @@ class _AvatarMenuContent extends StatelessWidget {
|
||||
// header reads as a continuation of the button that
|
||||
// opened it rather than a disconnected card.
|
||||
InkWell(
|
||||
onTap: closeAndOpenSettings,
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(NooSpace.md),
|
||||
child: Row(
|
||||
@@ -161,10 +169,87 @@ class _AvatarMenuContent extends StatelessWidget {
|
||||
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)
|
||||
@@ -201,3 +286,100 @@ class _AvatarMenuContent extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'noo/nav/noo_bottom_bar.dart';
|
||||
class BottomNavBar extends StatelessWidget {
|
||||
final NooNavStyle style;
|
||||
final NooBottomBarStyle barStyle;
|
||||
final bool frosted;
|
||||
final List<AppTab> tabs;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
@@ -25,6 +26,7 @@ class BottomNavBar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.style,
|
||||
this.barStyle = NooBottomBarStyle.attached,
|
||||
this.frosted = false,
|
||||
required this.tabs,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
@@ -36,6 +38,7 @@ class BottomNavBar extends StatelessWidget {
|
||||
return NooBottomBar(
|
||||
style: style,
|
||||
barStyle: barStyle,
|
||||
frosted: frosted,
|
||||
destinations: [
|
||||
for (final tab in tabs)
|
||||
NooNavDestination(icon: tab.icon, label: tab.label),
|
||||
|
||||
@@ -54,7 +54,7 @@ enum _DetailsTab { info, versions, activity }
|
||||
/// and the "open externally" flow for unsupported file types.
|
||||
///
|
||||
/// 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`.
|
||||
class DetailsSheet extends StatelessWidget {
|
||||
final NextcloudItem item;
|
||||
@@ -68,7 +68,7 @@ class DetailsSheet extends StatelessWidget {
|
||||
leading: detailsFileTile(item),
|
||||
title: item.name,
|
||||
subtitle: detailsMetaLine(item),
|
||||
children: [_DetailsBody(item: item)],
|
||||
children: [DetailsBody(item: item)],
|
||||
);
|
||||
}
|
||||
return showNooSheet(context, children: [DetailsSheet(item: item)]);
|
||||
@@ -86,7 +86,7 @@ class DetailsSheet extends StatelessWidget {
|
||||
onClose: () => Navigator.pop(context),
|
||||
),
|
||||
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
|
||||
/// tear down and rebuild whichever tab isn't showing, discarding its
|
||||
/// fetched data and re-requesting it every time the user switched back.
|
||||
class _DetailsBody extends StatefulWidget {
|
||||
class DetailsBody extends StatefulWidget {
|
||||
final NextcloudItem item;
|
||||
|
||||
const _DetailsBody({required this.item});
|
||||
const DetailsBody({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<_DetailsBody> createState() => _DetailsBodyState();
|
||||
State<DetailsBody> createState() => _DetailsBodyState();
|
||||
}
|
||||
|
||||
class _DetailsBodyState extends State<_DetailsBody> {
|
||||
class _DetailsBodyState extends State<DetailsBody> {
|
||||
_DetailsTab _tab = _DetailsTab.info;
|
||||
|
||||
@override
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_chip.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
import 'noo/core/noo_toggle.dart';
|
||||
import 'filter_mode_rows.dart';
|
||||
import 'noo/lists/noo_grouped_list.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/overlays/noo_sheet.dart';
|
||||
@@ -54,7 +54,7 @@ class FilesControlsRow extends StatelessWidget {
|
||||
final files = context.watch<FilesController>();
|
||||
final filtersActive =
|
||||
files.filesTypeFilter != FilesTypeFilter.all ||
|
||||
files.showHiddenFiles ||
|
||||
files.hiddenFilter != HiddenFilesFilter.hide ||
|
||||
(showStorageScope && files.storageScope != StorageScope.cloud);
|
||||
|
||||
return SizedBox(
|
||||
@@ -171,54 +171,46 @@ class FilesControlsRow extends StatelessWidget {
|
||||
// Same "icon always, label only when selected" pill as
|
||||
// Photos' own type filter below - not a checkmark list, so
|
||||
// the two screens' filter sheets look and behave the same.
|
||||
NooSegmentedControl<FilesTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: files.filesTypeFilter,
|
||||
onChanged: files.setFilesTypeFilter,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.filesOnly,
|
||||
icon: LucideIcons.file,
|
||||
label: 'Files',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.foldersOnly,
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Folders',
|
||||
),
|
||||
],
|
||||
FilterSection(
|
||||
icon: LucideIcons.layoutGrid,
|
||||
title: 'Type',
|
||||
child: NooSegmentedControl<FilesTypeFilter>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: files.filesTypeFilter,
|
||||
onChanged: files.setFilesTypeFilter,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.all,
|
||||
icon: LucideIcons.layoutGrid,
|
||||
label: 'All',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.filesOnly,
|
||||
icon: LucideIcons.file,
|
||||
label: 'Files',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: FilesTypeFilter.foldersOnly,
|
||||
icon: LucideIcons.folder,
|
||||
label: 'Folders',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
NooGroupedList(
|
||||
children: [
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.eye,
|
||||
label: const Text('Show hidden files'),
|
||||
trailing: NooToggle(
|
||||
checked: files.showHiddenFiles,
|
||||
onChanged: (_) => files.toggleShowHiddenFiles(),
|
||||
),
|
||||
),
|
||||
if (showStorageScope)
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: const Text('External storage'),
|
||||
trailing: NooToggle(
|
||||
checked: files.storageScope == StorageScope.external,
|
||||
onChanged: (external) => files.setStorageScope(
|
||||
external ? StorageScope.external : StorageScope.cloud,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
HiddenFilesFilterRow(
|
||||
value: files.hiddenFilter,
|
||||
onChanged: files.setHiddenFilter,
|
||||
),
|
||||
if (showStorageScope) ...[
|
||||
const SizedBox(height: 22),
|
||||
StorageScopeRow(
|
||||
value: files.storageScope,
|
||||
onChanged: files.setStorageScope,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../providers/files_controller.dart';
|
||||
import '../theme/design_tokens.dart';
|
||||
import 'noo/core/noo_segmented_control.dart';
|
||||
|
||||
/// "Hidden files" row of the Files/Photos filter sheets: hide them (the
|
||||
/// default), show only them, or show them alongside everything else. Same
|
||||
/// icon-always/label-when-selected pill as the type filter above it in
|
||||
/// both sheets.
|
||||
class HiddenFilesFilterRow extends StatelessWidget {
|
||||
final HiddenFilesFilter value;
|
||||
final ValueChanged<HiddenFilesFilter> onChanged;
|
||||
|
||||
const HiddenFilesFilterRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FilterModeRow<HiddenFilesFilter>(
|
||||
icon: LucideIcons.eye,
|
||||
title: 'Hidden files',
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.hide,
|
||||
icon: LucideIcons.eyeOff,
|
||||
label: 'Hide',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.only,
|
||||
icon: LucideIcons.eye,
|
||||
label: 'Only hidden',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: HiddenFilesFilter.include,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "External storage" row of the Files/Photos filter sheets: cloud only
|
||||
/// (the default), only external storage, or both. Files shows the external
|
||||
/// items in a collapsible section for [StorageScope.all].
|
||||
class StorageScopeRow extends StatelessWidget {
|
||||
final StorageScope value;
|
||||
final ValueChanged<StorageScope> onChanged;
|
||||
|
||||
const StorageScopeRow({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FilterModeRow<StorageScope>(
|
||||
icon: LucideIcons.hardDrive,
|
||||
title: 'External storage',
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: const [
|
||||
NooSegmentOption(
|
||||
value: StorageScope.cloud,
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'Cloud only',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: StorageScope.external,
|
||||
icon: LucideIcons.hardDrive,
|
||||
label: 'Only external',
|
||||
),
|
||||
NooSegmentOption(
|
||||
value: StorageScope.all,
|
||||
icon: LucideIcons.layers,
|
||||
label: 'All',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An icon + title line above [child] - the shape every row of the Files/
|
||||
/// Photos filter sheets shares, including their type (All/Files/Folders)
|
||||
/// control, which lives in its own file.
|
||||
class FilterSection extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const FilterSection({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: colors.fg2),
|
||||
const SizedBox(width: 10),
|
||||
Text(title, style: NooText.bodyL.copyWith(color: colors.fg1)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterModeRow<T> extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final T value;
|
||||
final ValueChanged<T> onChanged;
|
||||
final List<NooSegmentOption<T>> options;
|
||||
|
||||
const _FilterModeRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FilterSection(
|
||||
icon: icon,
|
||||
title: title,
|
||||
child: NooSegmentedControl<T>(
|
||||
fill: true,
|
||||
onSurface: true,
|
||||
labelOnlySelected: true,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
options: options,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,9 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
final Widget child;
|
||||
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
|
||||
/// explicit value (e.g. from user settings) to make it adjustable.
|
||||
final double blurSigma;
|
||||
@@ -35,6 +38,7 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
super.key,
|
||||
required this.child,
|
||||
this.borderRadius = 0,
|
||||
this.radius,
|
||||
this.blurSigma = 20,
|
||||
this.opacity = 0.8,
|
||||
this.color,
|
||||
@@ -42,15 +46,16 @@ class FrostedGlassContainer extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = radius ?? BorderRadius.circular(borderRadius);
|
||||
final tint = color ?? context.nooColors.surface;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderRadius: shape,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
borderRadius: shape,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
|
||||
@@ -22,6 +22,9 @@ class NooGroupedList extends StatefulWidget {
|
||||
final String? aside;
|
||||
final List<Widget> children;
|
||||
final Widget? footer;
|
||||
|
||||
/// Shown between [label] and the card (e.g. a `NooInfoNote`).
|
||||
final Widget? notice;
|
||||
final bool collapsible;
|
||||
final bool initiallyExpanded;
|
||||
|
||||
@@ -31,6 +34,7 @@ class NooGroupedList extends StatefulWidget {
|
||||
this.aside,
|
||||
required this.children,
|
||||
this.footer,
|
||||
this.notice,
|
||||
this.collapsible = false,
|
||||
this.initiallyExpanded = true,
|
||||
});
|
||||
@@ -58,6 +62,10 @@ class _NooGroupedListState extends State<NooGroupedList> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (widget.notice != null) ...[
|
||||
widget.notice!,
|
||||
const SizedBox(height: NooSpace.md),
|
||||
],
|
||||
AnimatedCrossFade(
|
||||
duration: NooMotion.fast,
|
||||
sizeCurve: NooMotion.ease,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:ui' show ImageFilter;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/design_tokens.dart';
|
||||
import 'noo_nav_style.dart';
|
||||
@@ -13,6 +14,11 @@ const double _kFloatingPillTop = 8;
|
||||
const double _kAndroidPillWidth = 56;
|
||||
const double _kAndroidPillHeight = 32;
|
||||
|
||||
/// Frosted glass: the surface fill's opacity and the backdrop blur sigma -
|
||||
/// the same sigma [FrostedGlassContainer] uses for the media viewer chrome.
|
||||
const double _kFrostedAlpha = 0.72;
|
||||
const double _kFrostedSigma = 20;
|
||||
|
||||
/// The mobile bottom bar (DESIGN_SYSTEM.md 3, "Mobile"; `iNav`/`aNav` in
|
||||
/// `Mobile Screen.dc.html`). Shows the 5 pinned tabs - which ones, and in
|
||||
/// what order, is the caller's business; this only draws [destinations].
|
||||
@@ -26,6 +32,9 @@ const double _kAndroidPillHeight = 32;
|
||||
/// 56x32 accent-soft pill that slides between tabs as selection moves,
|
||||
/// rather than popping in/out on the destination item itself.
|
||||
///
|
||||
/// [NooBottomBarStyle.floating] ignores [style] and always uses the Android
|
||||
/// row below, so iOS gets the same icon-only idle tabs.
|
||||
///
|
||||
/// [barStyle] (user-configurable in Settings, Appearance) picks between
|
||||
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
|
||||
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
|
||||
@@ -45,6 +54,13 @@ const double _kAndroidPillHeight = 32;
|
||||
/// body draws behind it - see `tab_state_slivers.dart`'s
|
||||
/// `bottomBarClearance`.
|
||||
///
|
||||
/// [frosted] (Settings, Appearance) swaps the solid `surface` fill for a
|
||||
/// translucent one over a backdrop blur, on both bar styles and both nav
|
||||
/// styles, and drops [nooDialogShadow] (a shadow would show through the
|
||||
/// glass). It only reads as glass if content draws behind the bar, so the
|
||||
/// host `Scaffold` needs `extendBody: true` and each scrollable body needs
|
||||
/// the same trailing clearance floating does - see [drawsBehindBody].
|
||||
///
|
||||
/// [searchDestination]/[onSearchTap] (set together, from Settings'
|
||||
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
|
||||
/// entry) add a Search entry that's never highlighted (tapping it pushes
|
||||
@@ -54,6 +70,7 @@ const double _kAndroidPillHeight = 32;
|
||||
class NooBottomBar extends StatelessWidget {
|
||||
final NooNavStyle style;
|
||||
final NooBottomBarStyle barStyle;
|
||||
final bool frosted;
|
||||
final List<NooNavDestination> destinations;
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onSelected;
|
||||
@@ -64,6 +81,7 @@ class NooBottomBar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.style,
|
||||
this.barStyle = NooBottomBarStyle.attached,
|
||||
this.frosted = false,
|
||||
required this.destinations,
|
||||
required this.selectedIndex,
|
||||
required this.onSelected,
|
||||
@@ -75,10 +93,18 @@ class NooBottomBar extends StatelessWidget {
|
||||
/// 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;
|
||||
if (style == NooNavStyle.ios && barStyle != NooBottomBarStyle.floating) {
|
||||
return 50;
|
||||
}
|
||||
return barStyle == NooBottomBarStyle.floating ? 64 : 80;
|
||||
}
|
||||
|
||||
/// Whether the host `Scaffold` must draw its body behind the bar
|
||||
/// (`extendBody`) - true for floating (its transparent margin) and for any
|
||||
/// frosted bar (there's nothing to blur otherwise).
|
||||
static bool drawsBehindBody(NooBottomBarStyle barStyle, bool frosted) =>
|
||||
frosted || barStyle == NooBottomBarStyle.floating;
|
||||
|
||||
/// Gap between the floating bar's bottom edge and the safe area below it
|
||||
/// (itself inside the [SafeArea] that consumes the actual device inset).
|
||||
static const double floatingBottomMargin = 12;
|
||||
@@ -86,8 +112,10 @@ class NooBottomBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
final ios = style == NooNavStyle.ios;
|
||||
final floating = barStyle == NooBottomBarStyle.floating;
|
||||
// Floating always gets the Android row (icon-only idle tabs, sliding
|
||||
// pill) - iOS's icon-over-label row doesn't fit a pill that short.
|
||||
final ios = style == NooNavStyle.ios && !floating;
|
||||
final hasSearch = searchDestination != null && onSearchTap != null;
|
||||
// Attached folds Search into the row itself (last item); floating
|
||||
// gives it a separate satellite circle instead (built below), so the
|
||||
@@ -104,17 +132,19 @@ class NooBottomBar extends StatelessWidget {
|
||||
final barHeight = NooBottomBar.rowHeight(style, barStyle);
|
||||
|
||||
if (floating) {
|
||||
final pill = Container(
|
||||
final pillRadius = BorderRadius.circular(28);
|
||||
final pillBox = Container(
|
||||
height: barHeight,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
color: _fill(colors),
|
||||
border: Border.all(color: colors.line),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: const [nooDialogShadow],
|
||||
borderRadius: pillRadius,
|
||||
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||
),
|
||||
child: row,
|
||||
);
|
||||
final pill = frosted ? _blurred(pillBox, pillRadius) : pillBox;
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
@@ -145,6 +175,7 @@ class NooBottomBar extends StatelessWidget {
|
||||
destination: searchDestination!,
|
||||
onTap: onSearchTap!,
|
||||
size: barHeight,
|
||||
frosted: frosted,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -154,9 +185,9 @@ class NooBottomBar extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
final bar = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
color: _fill(colors),
|
||||
border: ios ? Border(top: BorderSide(color: colors.line)) : null,
|
||||
),
|
||||
child: SafeArea(
|
||||
@@ -164,8 +195,13 @@ class NooBottomBar extends StatelessWidget {
|
||||
child: SizedBox(height: barHeight, child: row),
|
||||
),
|
||||
);
|
||||
return frosted ? _blurred(bar, BorderRadius.zero) : bar;
|
||||
}
|
||||
|
||||
Color _fill(NooColors colors) => frosted
|
||||
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
||||
: colors.surface;
|
||||
|
||||
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -248,6 +284,16 @@ class NooBottomBar extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clips [child] to [radius] and blurs whatever is drawn behind it - the
|
||||
/// frosted bar's backdrop. [child] supplies the translucent fill on top.
|
||||
Widget _blurred(Widget child, BorderRadius radius) => ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: _kFrostedSigma, sigmaY: _kFrostedSigma),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
/// 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.
|
||||
@@ -255,11 +301,13 @@ class _SearchSatellite extends StatelessWidget {
|
||||
final NooNavDestination destination;
|
||||
final VoidCallback onTap;
|
||||
final double size;
|
||||
final bool frosted;
|
||||
|
||||
const _SearchSatellite({
|
||||
required this.destination,
|
||||
required this.onTap,
|
||||
required this.size,
|
||||
required this.frosted,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -271,21 +319,28 @@ class _SearchSatellite extends StatelessWidget {
|
||||
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),
|
||||
),
|
||||
child: _satelliteBody(colors),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _satelliteBody(NooColors colors) {
|
||||
final disc = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: frosted
|
||||
? colors.surface.withValues(alpha: _kFrostedAlpha)
|
||||
: colors.surface,
|
||||
border: Border.all(color: colors.line),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: frosted ? null : const [nooDialogShadow],
|
||||
),
|
||||
child: Icon(destination.icon, size: 24, color: colors.fg1),
|
||||
);
|
||||
return frosted ? _blurred(disc, BorderRadius.circular(size / 2)) : disc;
|
||||
}
|
||||
}
|
||||
|
||||
class _IosItem extends StatelessWidget {
|
||||
|
||||
@@ -34,7 +34,10 @@ class NooShareSection extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)),
|
||||
child: Text(
|
||||
title,
|
||||
style: NooText.sectionTitle.copyWith(color: colors.fg1),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
@@ -60,10 +63,13 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
final String name;
|
||||
final String? subtitle;
|
||||
final bool owner;
|
||||
|
||||
/// Pill label for non-owners, e.g. "Can edit" / "Can view".
|
||||
final String permission;
|
||||
final VoidCallback? onPermissionTap;
|
||||
final bool permissionLoading;
|
||||
final String ownerLabel;
|
||||
|
||||
/// Replaces the owner label / permission pill (e.g. a plain search result).
|
||||
final Widget? trailing;
|
||||
|
||||
@@ -75,6 +81,7 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
this.owner = false,
|
||||
this.permission = 'Can edit',
|
||||
this.onPermissionTap,
|
||||
this.permissionLoading = false,
|
||||
this.ownerLabel = 'Owner',
|
||||
this.trailing,
|
||||
});
|
||||
@@ -121,7 +128,11 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
else if (owner)
|
||||
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
|
||||
else
|
||||
NooPermissionPill(label: permission, onTap: onPermissionTap),
|
||||
NooPermissionPill(
|
||||
label: permission,
|
||||
onTap: onPermissionTap,
|
||||
loading: permissionLoading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -133,8 +144,14 @@ class NooPersonAccessRow extends StatelessWidget {
|
||||
class NooPermissionPill extends StatelessWidget {
|
||||
final String label;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
@@ -152,9 +169,21 @@ class NooPermissionPill extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
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> {
|
||||
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) {
|
||||
if (_needsPeek || metrics.maxScrollExtent <= 0) return;
|
||||
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
|
||||
@@ -84,6 +88,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||
child: Column(
|
||||
key: _contentKey,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
|
||||
@@ -53,6 +53,17 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
children: [
|
||||
_ThemeRow(settings: settings),
|
||||
_BottomBarStyleRow(settings: settings),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.glassWater,
|
||||
label: const Text('Frosted glass bottom bar'),
|
||||
subtitle: const Text(
|
||||
'A translucent, blurred bottom bar - content scrolls behind it',
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: settings.bottomBarFrosted,
|
||||
onChanged: settings.setBottomBarFrosted,
|
||||
),
|
||||
),
|
||||
_NavMenuStyleRow(settings: settings),
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.search,
|
||||
@@ -69,6 +80,13 @@ class SettingsAppearanceSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
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),
|
||||
SettingsSection(
|
||||
title: 'Advanced appearance',
|
||||
children: [
|
||||
@@ -293,6 +311,64 @@ class _NavMenuStyleRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
|
||||
/// color instead of a fixed swatch.
|
||||
|
||||
@@ -33,27 +33,12 @@ class SettingsSection extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.nooColors;
|
||||
if (!NooLayout.isDesktop(context)) {
|
||||
final list = NooGroupedList(
|
||||
return NooGroupedList(
|
||||
label: title,
|
||||
notice: notice != null ? NooInfoNote(message: notice!) : null,
|
||||
footer: subtitle != null ? Text(subtitle!) : null,
|
||||
// Not collapsible: each mobile Settings section now renders on its
|
||||
// own pushed screen (see `account_view.dart`'s doc comment for the
|
||||
// menu-then-pushed-screen pattern), so there's no long single-scroll
|
||||
// page left to collapse sections *within* - an earlier design had
|
||||
// every section inline in one column and made them individually
|
||||
// collapsible for exactly that reason; that's gone now that each
|
||||
// one is already isolated on its own screen.
|
||||
children: children,
|
||||
);
|
||||
if (notice == null) return list;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
NooInfoNote(message: notice!),
|
||||
const SizedBox(height: NooSpace.md),
|
||||
list,
|
||||
],
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'noo/core/noo_avatar.dart';
|
||||
import 'noo/core/noo_button.dart';
|
||||
import 'noo/core/noo_chip.dart';
|
||||
import 'noo/core/noo_toggle.dart';
|
||||
import 'noo/lists/noo_grouped_list.dart';
|
||||
import 'noo/lists/noo_settings_row.dart';
|
||||
import 'noo/media/noo_photo_tile.dart';
|
||||
import 'noo/noo_layout.dart';
|
||||
@@ -98,6 +99,8 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
bool _isSearching = false;
|
||||
bool _isAddingPerson = false;
|
||||
bool _isTogglingLink = false;
|
||||
String? _pendingShareeKey;
|
||||
final Set<String> _updatingShareIds = {};
|
||||
bool _isSharingFile = false;
|
||||
bool _showInherited = false;
|
||||
final _peopleController = TextEditingController();
|
||||
@@ -183,15 +186,20 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _shareeKey(NextcloudSharee s) => '${s.shareTypeValue}:${s.shareWith}';
|
||||
|
||||
Future<void> _addSharee(NextcloudSharee sharee) async {
|
||||
if (_pendingShareeKey != null) return;
|
||||
final ops = context.read<ItemOperations>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
setState(() => _pendingShareeKey = _shareeKey(sharee));
|
||||
final share = await ops.createShare(
|
||||
path: widget.item.path,
|
||||
shareType: sharee.shareTypeValue,
|
||||
shareWith: sharee.shareWith,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _pendingShareeKey = null);
|
||||
if (share == null) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -353,36 +361,93 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small popup with the one action the compact [NooPersonAccessRow]
|
||||
/// permission pill has no room for - removing that person's access. There's
|
||||
/// no permission-update endpoint on [ItemOperations] yet, so the pill
|
||||
/// itself is read-only (see the rebuild report).
|
||||
/// Permission menu for a person row: Can view / Can edit, then removing
|
||||
/// access. Keeps the reshare bit (16) as it was.
|
||||
void _openPersonMenu(NextcloudShare share) {
|
||||
final label = share.sharedWithDisplayName ?? 'Shared';
|
||||
final canEdit = (share.permissions & 2) != 0;
|
||||
|
||||
void remove() {
|
||||
Navigator.pop(context);
|
||||
_removeShare(share);
|
||||
}
|
||||
|
||||
final row = NooSettingsRow(
|
||||
icon: LucideIcons.userMinus,
|
||||
label: const Text('Remove access'),
|
||||
danger: true,
|
||||
onTap: remove,
|
||||
void choose(bool edit) {
|
||||
Navigator.pop(context);
|
||||
_setPermission(share, edit);
|
||||
}
|
||||
|
||||
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)) {
|
||||
showNooDialog(context, title: label, children: [row]);
|
||||
showNooDialog(
|
||||
context,
|
||||
title: label,
|
||||
children: [Column(children: rows)],
|
||||
);
|
||||
} else {
|
||||
showNooSheet(
|
||||
context,
|
||||
children: [
|
||||
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(
|
||||
NooColors colors,
|
||||
SessionController session,
|
||||
@@ -416,7 +481,7 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
for (final sharee in _searchResults)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(NooRadii.input),
|
||||
onTap: () => _addSharee(sharee),
|
||||
onTap: _pendingShareeKey == null ? () => _addSharee(sharee) : null,
|
||||
child: NooPersonAccessRow(
|
||||
avatar: NooAvatar(
|
||||
initials: _initial(sharee.label),
|
||||
@@ -427,7 +492,13 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
),
|
||||
name: sharee.label,
|
||||
subtitle: sharee.subtitle,
|
||||
trailing: const SizedBox.shrink(),
|
||||
trailing: _pendingShareeKey == _shareeKey(sharee)
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (_searchResults.isNotEmpty)
|
||||
@@ -463,7 +534,10 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
? 'Invited by email'
|
||||
: null),
|
||||
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) ...[
|
||||
const SizedBox(height: NooSpace.xs),
|
||||
@@ -625,9 +699,22 @@ class _ShareSheetState extends State<ShareSheet> {
|
||||
const SizedBox(height: 22),
|
||||
NooShareSection(
|
||||
title: 'Share link',
|
||||
trailing: NooToggle(
|
||||
checked: hasLink,
|
||||
onChanged: _isTogglingLink ? null : _toggleLink,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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
|
||||
? null
|
||||
|
||||
@@ -97,19 +97,26 @@ Widget tabEmptySliver(
|
||||
/// 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
|
||||
/// and frosted bars (see [NooBottomBar.drawsBehindBody]) draw over an
|
||||
/// extended body instead, so nothing reserves space for
|
||||
/// them automatically: the clearance has to cover the bar's own footprint
|
||||
/// (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 barStyle = settings.bottomBarStyle;
|
||||
if (!NooBottomBar.drawsBehindBody(barStyle, settings.bottomBarFrosted)) {
|
||||
return 100;
|
||||
}
|
||||
final barHeight = NooBottomBar.rowHeight(
|
||||
NooLayout.navStyle(context),
|
||||
NooBottomBarStyle.floating,
|
||||
barStyle,
|
||||
);
|
||||
final margin = barStyle == NooBottomBarStyle.floating
|
||||
? NooBottomBar.floatingBottomMargin
|
||||
: 0;
|
||||
final safeBottom = MediaQuery.paddingOf(context).bottom;
|
||||
return barHeight + NooBottomBar.floatingBottomMargin + safeBottom + 24;
|
||||
return barHeight + margin + safeBottom + 24;
|
||||
}
|
||||
|
||||
/// Bottom padding sliver so the last row/card clears the bottom nav/FAB -
|
||||
|
||||
@@ -22,9 +22,14 @@ class MediaActionBar extends StatelessWidget {
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onOpenExternally;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onDetails;
|
||||
final VoidCallback? onDetails;
|
||||
final Widget? handle;
|
||||
final Widget? below;
|
||||
|
||||
const MediaActionBar({
|
||||
this.onDetails,
|
||||
this.handle,
|
||||
this.below,
|
||||
super.key,
|
||||
required this.isFavorite,
|
||||
required this.isBusy,
|
||||
@@ -35,7 +40,6 @@ class MediaActionBar extends StatelessWidget {
|
||||
required this.onDelete,
|
||||
required this.onOpenExternally,
|
||||
required this.onDownload,
|
||||
required this.onDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -48,11 +52,15 @@ class MediaActionBar extends StatelessWidget {
|
||||
// short and leaving that strip unstyled - only the actual row content
|
||||
// needs padding up and away from the gesture area.
|
||||
return FrostedGlassContainer(
|
||||
radius: const BorderRadius.vertical(
|
||||
top: Radius.circular(NooRadii.sheetTop),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
?handle,
|
||||
if (transportControls != null) ...[
|
||||
transportControls!,
|
||||
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
|
||||
@@ -94,17 +102,18 @@ class MediaActionBar extends StatelessWidget {
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.trash2,
|
||||
tooltip: 'Delete',
|
||||
color: colors.danger,
|
||||
onTap: isBusy ? null : onDelete,
|
||||
),
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.info,
|
||||
tooltip: 'Details',
|
||||
onTap: onDetails,
|
||||
),
|
||||
if (onDetails != null)
|
||||
ViewerIconButton(
|
||||
icon: LucideIcons.info,
|
||||
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.
|
||||
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({
|
||||
super.key,
|
||||
this.topInset = 0,
|
||||
required this.item,
|
||||
required this.session,
|
||||
this.localPathResolver,
|
||||
@@ -29,8 +33,14 @@ class MediaPdfPreview extends StatefulWidget {
|
||||
}
|
||||
|
||||
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;
|
||||
String? _error;
|
||||
List<Size> _pageSizes = const [];
|
||||
int _pageCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -55,9 +65,21 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
|
||||
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(() {
|
||||
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes));
|
||||
_pageSizes = sizes;
|
||||
_pageCount = document.pagesCount;
|
||||
_controller = PdfControllerPinch(document: Future.value(document));
|
||||
});
|
||||
} catch (e) {
|
||||
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
|
||||
// margin from here - it isn't an exposed parameter - so this avoids
|
||||
// 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,5 +1,5 @@
|
||||
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
|
||||
# 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
|
||||
@@ -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
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.1+4
|
||||
version: 1.0.2+5
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
@@ -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,129 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:noo/models/nextcloud_item.dart';
|
||||
import 'package:noo/providers/connectivity_controller.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/providers/session_controller.dart';
|
||||
|
||||
NextcloudItem _item(String path, {String? mountType}) => NextcloudItem(
|
||||
id: path,
|
||||
name: path.split('/').last,
|
||||
path: path,
|
||||
type: NextcloudItemType.file,
|
||||
size: 1,
|
||||
lastModified: DateTime(2026),
|
||||
mountType: mountType,
|
||||
);
|
||||
|
||||
/// The shared hidden / storage-scope filtering (`applyCommonFilters`) and
|
||||
/// how a pref saved before the three-way hidden filter is read back.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUpAll(() {
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
|
||||
(call) async => call.method == 'readAll' ? <String, String>{} : null,
|
||||
);
|
||||
messenger.setMockMethodCallHandler(
|
||||
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
|
||||
(call) async => call.method == 'check' ? <String>['none'] : null,
|
||||
);
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
|
||||
MockStreamHandler.inline(onListen: (arguments, events) {}),
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
final items = [
|
||||
_item('/a.txt'),
|
||||
_item('/.secret'),
|
||||
_item('/docs/.git/config'),
|
||||
_item('/nas', mountType: 'external'),
|
||||
_item('/.nas-hidden', mountType: 'external'),
|
||||
];
|
||||
List<String> paths(List<NextcloudItem> l) => l.map((i) => i.path).toList();
|
||||
|
||||
FilesController build() =>
|
||||
FilesController(SessionController(ConnectivityController()));
|
||||
|
||||
test('hidden filter: hide / only / include', () {
|
||||
final files = build()..setStorageScope(StorageScope.all);
|
||||
List<String> run(HiddenFilesFilter h) => paths(
|
||||
files.applyCommonFilters(items, showFavoritesOnly: false, hidden: h),
|
||||
);
|
||||
expect(run(HiddenFilesFilter.hide), ['/a.txt', '/nas']);
|
||||
expect(run(HiddenFilesFilter.only), [
|
||||
'/.secret',
|
||||
'/docs/.git/config',
|
||||
'/.nas-hidden',
|
||||
]);
|
||||
expect(run(HiddenFilesFilter.include), paths(items));
|
||||
});
|
||||
|
||||
test('storage scope: cloud / external / all', () {
|
||||
final files = build();
|
||||
List<String> run(StorageScope s) {
|
||||
files.setStorageScope(s);
|
||||
return paths(
|
||||
files.applyCommonFilters(
|
||||
items,
|
||||
showFavoritesOnly: false,
|
||||
hidden: HiddenFilesFilter.include,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expect(run(StorageScope.cloud), [
|
||||
'/a.txt',
|
||||
'/.secret',
|
||||
'/docs/.git/config',
|
||||
]);
|
||||
expect(run(StorageScope.external), ['/nas', '/.nas-hidden']);
|
||||
expect(run(StorageScope.all), paths(items));
|
||||
});
|
||||
|
||||
test('hidden + external combine', () {
|
||||
final files = build()..setStorageScope(StorageScope.external);
|
||||
expect(
|
||||
paths(
|
||||
files.applyCommonFilters(
|
||||
items,
|
||||
showFavoritesOnly: false,
|
||||
hidden: HiddenFilesFilter.only,
|
||||
),
|
||||
),
|
||||
['/.nas-hidden'],
|
||||
);
|
||||
});
|
||||
|
||||
group('loadHiddenFilter', () {
|
||||
Future<HiddenFilesFilter> load(Map<String, Object> values) async {
|
||||
SharedPreferences.setMockInitialValues(values);
|
||||
return FilesController.loadHiddenFilter(
|
||||
await SharedPreferences.getInstance(),
|
||||
'new',
|
||||
'old',
|
||||
);
|
||||
}
|
||||
|
||||
test('defaults to hide', () async {
|
||||
expect(await load({}), HiddenFilesFilter.hide);
|
||||
});
|
||||
|
||||
test('the old show-hidden bool maps to hide / include', () async {
|
||||
expect(await load({'old': true}), HiddenFilesFilter.include);
|
||||
expect(await load({'old': false}), HiddenFilesFilter.hide);
|
||||
});
|
||||
|
||||
test('the new key wins and an unknown value falls back to hide', () async {
|
||||
expect(await load({'new': 'only', 'old': true}), HiddenFilesFilter.only);
|
||||
expect(await load({'new': 'bogus'}), HiddenFilesFilter.hide);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:noo/providers/files_controller.dart';
|
||||
import 'package:noo/widgets/filter_mode_rows.dart';
|
||||
|
||||
import 'noo/noo_test_utils.dart';
|
||||
|
||||
void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('HiddenFilesFilterRow', () {
|
||||
testNooWidgets(
|
||||
'only the selected option shows its label, tapping another reports it',
|
||||
(tester, theme, c) async {
|
||||
var value = HiddenFilesFilter.hide;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 360,
|
||||
child: HiddenFilesFilterRow(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('Hidden files'), findsOneWidget);
|
||||
expect(find.text('Hide'), findsOneWidget);
|
||||
expect(find.text('Only hidden'), findsNothing);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, HiddenFilesFilter.include);
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('StorageScopeRow', () {
|
||||
testNooWidgets('offers cloud only / only external / all', (
|
||||
tester,
|
||||
theme,
|
||||
c,
|
||||
) async {
|
||||
var value = StorageScope.cloud;
|
||||
await pumpNoo(
|
||||
tester,
|
||||
StatefulBuilder(
|
||||
builder: (context, setState) => SizedBox(
|
||||
width: 360,
|
||||
child: StorageScopeRow(
|
||||
value: value,
|
||||
onChanged: (v) => setState(() => value = v),
|
||||
),
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(find.text('External storage'), findsOneWidget);
|
||||
expect(find.text('Cloud only'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.hardDrive).last);
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, StorageScope.external);
|
||||
expect(find.text('Only external'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byIcon(LucideIcons.layers));
|
||||
await tester.pumpAndSettle();
|
||||
expect(value, StorageScope.all);
|
||||
expect(find.text('All'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -74,6 +74,30 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('NooColors.fromDynamicScheme surfaces', () {
|
||||
for (final brightness in Brightness.values) {
|
||||
test('cards are clearly lighter than the page (${brightness.name})', () {
|
||||
// The OS palette overrides `surface` (what `bg` maps to) but not the
|
||||
// seeded container roles - mimic that mismatch.
|
||||
final seeded = ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF3F6B8F),
|
||||
brightness: brightness,
|
||||
);
|
||||
final os = seeded.copyWith(
|
||||
surface: brightness == Brightness.light
|
||||
? const Color(0xFFF7F9FC)
|
||||
: const Color(0xFF101417),
|
||||
);
|
||||
final c = NooColors.fromDynamicScheme(os);
|
||||
// WCAG-style contrast ratio between the card and the page.
|
||||
final hi = c.surface.computeLuminance();
|
||||
final lo = c.bg.computeLuminance();
|
||||
expect(hi, greaterThan(lo));
|
||||
expect((hi + 0.05) / (lo + 0.05), greaterThan(1.04));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('NooAvatar', () {
|
||||
testNooWidgets('current user uses accent-soft/accent-text', (
|
||||
tester,
|
||||
|
||||
@@ -25,6 +25,69 @@ void main() {
|
||||
setUpNooTests();
|
||||
|
||||
group('NooBottomBar', () {
|
||||
for (final style in NooNavStyle.values) {
|
||||
for (final barStyle in NooBottomBarStyle.values) {
|
||||
testNooWidgets(
|
||||
'$style/$barStyle: frosted draws a backdrop blur, solid does not',
|
||||
(tester, theme, c) async {
|
||||
for (final frosted in [false, true]) {
|
||||
await pumpNoo(
|
||||
tester,
|
||||
SizedBox(
|
||||
width: 360,
|
||||
height: 120,
|
||||
child: NooBottomBar(
|
||||
style: style,
|
||||
barStyle: barStyle,
|
||||
frosted: frosted,
|
||||
destinations: _destinations,
|
||||
selectedIndex: 0,
|
||||
onSelected: (_) {},
|
||||
searchDestination: const NooNavDestination(
|
||||
icon: LucideIcons.search,
|
||||
label: 'Search',
|
||||
),
|
||||
onSearchTap: () {},
|
||||
),
|
||||
),
|
||||
theme: theme,
|
||||
);
|
||||
expect(
|
||||
find.byType(BackdropFilter),
|
||||
frosted ? findsWidgets : findsNothing,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test('drawsBehindBody: floating or frosted', () {
|
||||
expect(
|
||||
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
NooBottomBar.drawsBehindBody(NooBottomBarStyle.attached, true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
NooBottomBar.drawsBehindBody(NooBottomBarStyle.floating, false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('floating row height is the Android one on both nav styles', () {
|
||||
expect(
|
||||
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.floating),
|
||||
64,
|
||||
);
|
||||
expect(
|
||||
NooBottomBar.rowHeight(NooNavStyle.ios, NooBottomBarStyle.attached),
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
testNooWidgets('android: label sits below the icon, not beside it', (
|
||||
tester,
|
||||
theme,
|
||||
|
||||