Author SHA1 Message Date
ayushyaandClaude Code 6ad37e4a36 Force LF endings on shell scripts
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:31:11 -04:00
ayushyaandClaude Code 1df27bdc13 Add GitHub PR import script, PR check workflow, issue templates
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:31:11 -04:00
ayushya 3a4d6142e7 Update README.md 2026-10-01 19:46:19 -04:00
ayushyaandClaude Code 5136ea2eb3 Prepare repository for public release
Add PolyForm Shield license, contributing and security docs, PR and commit
templates; rewrite README; ignore local Claude and signing files.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 19:45:17 -04:00
ayushyaandClaude Code d938df6aee Auto-publish Release-* builds to the Play internal track
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:48:21 -04:00
ayushyaandClaude Code 4611bc2142 Media viewer details panel, Upload FAB size setting, PDF zoom/viewport fixes, avatar menu account switcher; bump to 1.0.2+5
Build App Bundle / build (push) Successful in 6m2s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 18:41:51 -04:00
ayushyaandClaude Code 0b7df833f6 Tablet layout polish: tablet-only sidebar layout, floating toolbar, Settings submenus, info notes, adaptive grids; bump to 1.0.1+4
Build App Bundle / build (push) Successful in 6m0s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 17:08:22 -04:00
ayushyaandClaude Code ed747a0407 Upload release AAB to Google Play internal track from CI
Build App Bundle / build (push) Successful in 7m36s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 16:18:45 -04:00
ayushyaandClaude Code 9c9e57ec22 Set app version to 1.0.0+3; share sheet and markdown viewer polish; remove demo screenshot tooling
Build App Bundle / build (push) Successful in 5m22s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 14:50:07 -04:00
70 changed files with 1962 additions and 1537 deletions
+3 -1
View File
@@ -275,7 +275,9 @@ bare system prompt gives no context on its own.
`MainShellView` builds its chrome from the Noo nav kit `MainShellView` builds its chrome from the Noo nav kit
(`widgets/noo/nav/`) and switches between two layouts on (`widgets/noo/nav/`) and switches between two layouts on
`NooLayout.isDesktop`: `NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
phone in landscape keeps the mobile layout and only tablet-class windows get
the sidebar):
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar` - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
for *every* tab (previously only Files had shell-level top chrome, with for *every* tab (previously only Files had shell-level top chrome, with
+10
View File
@@ -801,3 +801,13 @@ Flutter's own default to already be high enough.
`isRestoringSession` still gates the splash screen until the above resolves `isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels — see `standards.md` for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally. rather than relying on this async path throwing naturally.
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
## Changing a share's permissions
`NextcloudService.updateSharePermissions` does `PUT
/ocs/v2.php/apps/files_sharing/api/v1/shares/{id}` with a `permissions`
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
sheet's per-person pill opens a menu with Can view (1) / Can edit (3 for
files, 15 for folders), keeping the existing reshare bit, plus Remove access.
+23 -26
View File
@@ -90,32 +90,6 @@ class/method already makes obvious.
`Align`). `Align`).
- Run with `flutter test`. - Run with `flutter test`.
## Play Store screenshots
`bash tool/screenshots.sh` (Git Bash is fine on Windows) regenerates the store
listing screenshots **without any real data**: it runs the real, unmodified app
against a throwaway Docker Nextcloud (`tool/demo_server/`) seeded with invented
content, on a wiped 1080x1920 emulator with a demo-mode status bar, and drives
it with `integration_test/store_screenshots_test.dart` (host-side driver:
`test_driver/integration_test.dart`, which saves `adb screencap` images).
Output lands in `store_listing/screenshots/` (gitignored) after
`tool/finalize_screenshots.py` flattens alpha and checks Play's size/aspect
limits (each side 320-3840 px, long side at most 2x the short side - a stock
1080x2400 phone screen is 2.22:1 and would be rejected).
- The test logs in by writing the demo user's account/app password into
storage via `AccountStore` before calling `main()`, so it skips the browser
login flow; it also fixes the theme (light, non-dynamic colour), the visible
tabs and the first-run notification prompt so shots are reproducible.
- It finds things by tab icon (`AppTab.icon` inside `FloatingBottomNavBar`),
tooltips, the `ValueKey('files')`/`ValueKey('offline')` on the two
`FilesView`s, and the fake content's names (defined in
`tool/demo_server/seed.py`) - keep those in sync if you rename either side.
- `flutter test` only runs `test/`, so this never runs as part of the normal
suite; `integration_test` is a dev-only dependency.
- Always review the images before uploading - see
`tool/demo_server/README.md` for the known places real data could appear.
## Local install/deploy ## Local install/deploy
Never use `flutter install` to push a build to a test device — it always Never use `flutter install` to push a build to a test device — it always
@@ -157,3 +131,26 @@ Networking is deliberately split: `package:http` for simple JSON/XML
request-response calls, `package:dio` only where streaming/progress is request-response calls, `package:dio` only where streaming/progress is
needed (downloads). Don't introduce a third HTTP client — extend the needed (downloads). Don't introduce a third HTTP client — extend the
existing split instead. existing split instead.
## Play Store upload (CI)
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
`internal` track with `status: completed`, so it rolls out to internal
testers automatically (promote to other tracks in Play Console). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
repo secret (service account with release permissions; the app's first
release must be uploaded manually). Play rejects a repeated `versionCode`, so
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
will fail the upload. Release notes come from
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
## Community PR flow
Gitea is the source of truth; GitHub is a push mirror (never commit to GitHub
`main`) and the public issue tracker. `scripts/sync-github-prs.sh` (cron on the
home server) imports open GitHub PRs into Gitea via Agit
(`refs/for/main`, topic `gh-pr-N`), where `.gitea/workflows/pr.yml` runs
analyze + tests. That workflow must never use secrets; signing and Play
credentials stay on the tag-triggered `build.yml`/`release.yml`. Issue
templates are in `.github/ISSUE_TEMPLATE/`.
+58 -7
View File
@@ -30,10 +30,12 @@ widgets. Key points:
OS-provided `ColorScheme` wins over the seed color — always thread both OS-provided `ColorScheme` wins over the seed color — always thread both
`dynamicScheme` and `useDynamicColor` through when adding a theme knob. `dynamicScheme` and `useDynamicColor` through when adding a theme knob.
- **Font**: Inter via `google_fonts`, applied through - **Font**: Inter via `google_fonts`, applied through
`GoogleFonts.interTextTheme(...)`. In tests, set `GoogleFonts.interTextTheme(...)`. Font files ship in
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without `assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
it, the font-fetch call to Google's CDN can stall `pumpAndSettle` `main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
indefinitely (see `standards.md`). app never contacts Google (the privacy policy on the website relies on
this — keep it true; add new weights as bundled files). Tests set the
same flag in `setUpAll` (see `standards.md`).
- **Cards**: flat (`elevation: 0`), 20px rounded corners, - **Cards**: flat (`elevation: 0`), 20px rounded corners,
`surfaceContainerLow`. `surfaceContainerLow`.
- **App bars**: flat, not centered, `surface` background. - **App bars**: flat, not centered, `surface` background.
@@ -78,10 +80,10 @@ Catalog:
| Folder | Components | | Folder | Components |
|---|---| |---|---|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | | `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard`, `NooSelectionBar` | | `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` | | `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` | | `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` | | `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` | | `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
Gotchas: Gotchas:
@@ -196,6 +198,27 @@ blocks are noted where they matter:
bars and video transport controls (its other former user, the floating bars and video transport controls (its other former user, the floating
bottom nav bar, is gone - see below). Reuse this for any new floating bottom nav bar, is gone - see below). Reuse this for any new floating
overlay instead of building a new blur/shadow combo. overlay instead of building a new blur/shadow combo.
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
expanded always labelled. Phone/Android layout only.
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
out past `viewport.height / doc.height`, so for short documents (<= 3
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
the boundary height reaches the viewport and fit-width stays reachable
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
+ top bar height, 0 when the controls are hidden) animates the viewport
below the overlaid top bar.
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
under the label, above the card; `SettingsSection` uses it for its
`notice` on phones.
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
the media viewer's bottom bar as a draggable panel (mobile/phone only;
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
Details button, or swiping up on swipeable media (raw `Listener` in
`file_viewer_screen.dart`) grows it upward so the action row rides on top
of the details; tapping the media collapses it.
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and - `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/ `lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart` `swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
@@ -230,7 +253,11 @@ blocks are noted where they matter:
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) - `ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
a caller passing a custom `onTap` *must* also pass a matching `label`, or 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 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` + `showGeneralDialog` directly (`barrierColor: Colors.transparent` +
`barrierDismissible: true` for a non-dimming click-outside-to-close menu, `barrierDismissible: true` for a non-dimming click-outside-to-close menu,
not a modal flow) - there's no existing anchored-popup primitive here not a modal flow) - there's no existing anchored-popup primitive here
@@ -324,3 +351,27 @@ blocks are noted where they matter:
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
layer), so it renders at a sensible size at 72-80px. layer), so it renders at a sensible size at 72-80px.
## Text/markdown viewer
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor.
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
## Desktop/tablet layout notes
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
system bars clear of the sidebar; the sidebar has no divider between pinned
and hidden tabs.
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
pushed category screens render the desktop card layout.
- Settings that don't apply in wide-tablet (Appearance nav options,
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
Photos) take their column count from `NooLayout.gridColumns` (min tile
width, never below the phone count), so a tablet in portrait gets more
columns rather than a few huge tiles.
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+26
View File
@@ -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
+26
View File
@@ -39,6 +39,16 @@ jobs:
# platform/build-tools versions it actually needs on its own. # platform/build-tools versions it actually needs on its own.
packages: "platform-tools" packages: "platform-tools"
- name: Check tag matches pubspec version
# Play rejects a repeated versionCode and a mismatched tag would
# publish the wrong version name, so fail fast before building.
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
if [ "${GITHUB_REF_NAME}" != "Release-${VERSION}" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match pubspec version ${VERSION}" >&2
exit 1
fi
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get
@@ -67,6 +77,7 @@ jobs:
mkdir -p release mkdir -p release
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab" cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV" echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Publish Gitea release with App Bundle attached - name: Publish Gitea release with App Bundle attached
# Gitea-native release action (not GitHub's) - authenticates and # Gitea-native release action (not GitHub's) - authenticates and
@@ -77,3 +88,18 @@ jobs:
uses: akkuman/gitea-release-action@v1 uses: akkuman/gitea-release-action@v1
with: with:
files: ${{ env.AAB_PATH }} files: ${{ env.AAB_PATH }}
- 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
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: dev.ayushya.noo
releaseFiles: ${{ env.AAB_PATH }}
releaseName: ${{ env.VERSION }}
track: internal
status: completed
whatsNewDirectory: distribution/whatsnew
+33
View File
@@ -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.
+5
View File
@@ -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
+27
View File
@@ -0,0 +1,27 @@
## Summary
<!-- What does this change, and why? Link the issue it closes, e.g. "Closes #123". -->
## Changes
-
## Screenshots / recordings
<!-- Required for UI changes: before/after, light + dark, phone + tablet width where relevant. Delete if not applicable. -->
## Testing
<!-- How did you verify this? Device/emulator and OS version for anything platform-specific. -->
- [ ] `flutter analyze` is clean
- [ ] `flutter test` passes
- [ ] Tried it on a device or emulator (UI / platform changes)
- [ ] Tested against a real Nextcloud server (server / sync changes)
## Checklist
- [ ] New or changed behavior has tests where practical
- [ ] Docs in `.claude/context/` updated if architecture, server integration, styling, or standards changed
- [ ] No secrets, server URLs, credentials, or personal data in code, tests, or screenshots
- [ ] Commits follow the commit message template (`.gitmessage`)
+11 -3
View File
@@ -45,6 +45,14 @@ app.*.map.json
/android/app/profile /android/app/profile
/android/app/release /android/app/release
# Play Store screenshot tooling (tool/screenshots.sh) # Local Claude Code state
/store_listing/screenshots/ .claude/worktrees/
/tool/demo_server/.venv/ .claude/settings.local.json
.claude/scheduled_tasks.lock
# Signing / secrets (never commit)
*.keystore
*.jks
key.properties
.env
.env.*
+17
View File
@@ -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
+78
View File
@@ -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.
+91
View File
@@ -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.
+67 -32
View File
@@ -1,36 +1,46 @@
# Noo # Noo
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
and Material You. and Material 3.
Noo gives you a clean, modern way to browse and manage the files on your own Noo is a clean, modern way to browse and manage the files on your own
Nextcloud server — files, photos, shares, activity, and trash, all in one Nextcloud server: files, photos, shares, activity, and trash in one app,
app, themed to match your device. themed to match your device and laid out for both phones and tablets.
Website: <https://noo.ayushya.dev>
> Noo is an independent, unofficial client. It is not affiliated with or
> endorsed by Nextcloud GmbH.
## Features ## Features
- **Files** — browse, upload, move, rename, delete, and favorite files and - **Files**: browse, upload, move, copy, rename, delete, and favorite files
folders; sort and filter per folder and folders; sort and filter per folder; list or grid view
- **Photos** — a dedicated media gallery with a full-screen viewer for images - **Photos**: a media gallery with a full-screen viewer for images and video
and video - **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
- **Activity** — a feed of what's changed on your server pull-up details panel (info, activity, sharing)
- **Shares** — see and manage what you've shared and what's shared with you - **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
- **Trash** — restore or permanently delete recently removed items have shared, what changed, and what you deleted
- **Material You theming** — dynamic color on supported devices, five - **Search** across your server
built-in accent colors, light/dark/AMOLED - **Offline and sync**: keep files available offline and sync chosen folders
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign in the background
in through your browser, and Noo never sees or stores your password, - **Multiple accounts**: switch between saved accounts from the avatar menu
only a scoped app token - **App lock**: optionally require device authentication to open the app,
switch accounts, or reveal hidden files
## Screenshots - **Share to Noo**: upload from any app's share sheet
- **Make it yours**: Material You dynamic color, five accent colors,
_Coming soon._ light/dark/AMOLED, attached or floating bottom bar, hamburger or avatar
navigation, adjustable upload button, and a tablet layout with a sidebar
- **Secure sign-in**: authenticates through Nextcloud's Login Flow v2. You
sign in through your browser, and Noo never sees or stores your password,
only a scoped app password
## Getting started ## Getting started
Prerequisites: Prerequisites:
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`) - [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
- An Android device or emulator, an iOS device, or Windows
- A Nextcloud server with Login Flow v2 enabled (on by default since - A Nextcloud server with Login Flow v2 enabled (on by default since
Nextcloud 15) Nextcloud 15)
@@ -39,38 +49,63 @@ flutter pub get
flutter run flutter run
``` ```
On first launch, enter your server's address — Noo opens your browser to On first launch, enter your server's address. Noo opens your browser to
finish signing in. finish signing in.
## Supported platforms
Android, iOS, and Windows.
## Building ## Building
```bash ```bash
flutter build apk # Android flutter build apk # Android (debug-signed unless you configure a key)
flutter build ios # iOS flutter build ios # iOS
flutter build windows # Windows flutter build windows # Windows
``` ```
Release builds read signing details from `android/key.properties` or from
`RELEASE_KEYSTORE_*` environment variables (see `android/app/build.gradle.kts`).
Neither the keystore nor `key.properties` is, or should ever be, committed.
Without them, release builds fall back to debug signing.
## Tech stack ## Tech stack
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
and OCS APIs — no bundled Nextcloud SDK. OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
transfers and background sync.
## Contributing ## Contributing
Project context for contributors (human or AI) lives in Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server first. In short:
integration, styling conventions, and code standards.
```bash ```bash
flutter test # run tests flutter test # run tests
flutter analyze # static analysis / lints flutter analyze # static analysis / lints
``` ```
Architecture, server integration, styling conventions, and code standards are
documented in [`CLAUDE.md`](CLAUDE.md) and `.claude/context/`, written for
both human and AI contributors.
## Privacy
Noo talks only to the Nextcloud server you point it at. It has no analytics
and no third-party tracking. Read the full [privacy policy](https://noo.ayushya.dev/privacy).
## Acknowledgements
Typefaces [Instrument Sans](https://fonts.google.com/specimen/Instrument+Sans)
and [Schibsted Grotesk](https://fonts.google.com/specimen/Schibsted+Grotesk)
are bundled under the SIL Open Font License, and icons are from
[Lucide](https://lucide.dev). Those keep their own licenses; the license
below covers Noo's own code and assets.
## License ## License
Not yet licensed for redistribution — license TBD. Noo is source-available under the
[PolyForm Shield License 1.0.0](LICENSE). In plain terms: you may read, build,
run, and modify the code, and contribute changes back, but you may not use it
to make a product or service that competes with Noo. Please do not republish
it as your own app.
Contributions are welcome under the terms in
[`CONTRIBUTING.md`](CONTRIBUTING.md).
+8
View File
@@ -0,0 +1,8 @@
# Security policy
If you find a security problem in Noo, please email
[hello@ayushya.dev](mailto:hello@ayushya.dev) instead of opening a public
issue. Include steps to reproduce and the version (`pubspec.yaml`). I'll
acknowledge reports as soon as I can and credit you in the fix if you'd like.
Never include real server URLs, credentials, or personal files in a report.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Improved tablet layout: sidebar navigation, floating title bars, and adaptive grids. Long-press selection and Settings submenus now work on tablets.
@@ -1,325 +0,0 @@
// Drives the real app through its tabs and states against the fake-data demo
// server and captures the Play Store screenshots. Not part of the normal test
// suite (`flutter test` only runs `test/`) - run it through
// `tool/screenshots.sh`, which starts the demo server, an emulator and the
// driver in `test_driver/integration_test.dart` that saves the images.
//
// The app starts already logged in: the demo user's credentials are written
// straight into storage before `main()` runs, so Login Flow v2's browser
// step is skipped.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:noo/main.dart' as app;
import 'package:noo/models/app_tab.dart';
import 'package:noo/models/saved_account.dart';
import 'package:noo/providers/files_controller.dart';
import 'package:noo/providers/settings_controller.dart';
import 'package:noo/providers/sync_status_controller.dart';
import 'package:noo/services/account_store.dart';
import 'package:noo/theme/design_tokens.dart';
import 'package:noo/widgets/bottom_nav_bar.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _serverUrl = String.fromEnvironment(
'DEMO_SERVER_URL',
defaultValue: 'http://localhost',
);
const _username = String.fromEnvironment('DEMO_USERNAME', defaultValue: 'Alex');
const _appPassword = String.fromEnvironment('DEMO_APP_PASSWORD');
void main() {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'Play Store screenshots',
(tester) async {
expect(
_appPassword,
isNotEmpty,
reason: 'Pass the demo user\'s app password via '
'--dart-define=DEMO_APP_PASSWORD=... (tool/screenshots.sh does).',
);
await _seedLoggedInStorage();
app.main();
await tester.pump();
await _pumpUntil(tester, find.byType(app.MainShellView));
// Android only: required before `takeScreenshot()` can capture
// anything at all.
if (Platform.isAndroid) await binding.convertFlutterSurfaceToImage();
Future<void> shoot(String name) async {
// Let scroll/transition animations and image fades finish first.
await tester.pump(const Duration(seconds: 1));
await binding.takeScreenshot(name);
}
// ---- 01 Files (list) ------------------------------------------------
final filesTab = find.byKey(const ValueKey('files'));
await _pumpUntil(
tester,
find.descendant(of: filesTab, matching: find.text('Documents')),
);
await shoot('01_Files');
// ---- 02 Files (grid, inside a folder of photos) --------------------
final files = _read<FilesController>(tester);
await files.navigateToAbsoluteFolder('/Trips');
await _pumpUntil(
tester,
find.descendant(of: filesTab, matching: find.text('Lake sunrise.jpg')),
);
// The controls row scrolls horizontally and doesn't fit every control
// on a narrow screen, so "Grid view" can start off past the clipped
// edge - not just off the *current* tab (which `.hitTestable()` alone
// would handle), but off the *visible part of this tab's own row*.
// `_tapInTab` scrolls it into view first.
await _tapInTab(tester, filesTab, find.byTooltip('Grid view'));
await tester.pump(const Duration(seconds: 4)); // thumbnails
await shoot('02_Files_Grid');
// ---- 07 File viewer -------------------------------------------------
await _tapInTab(tester, filesTab, find.text('Lake sunrise.jpg'));
await tester.pump(const Duration(seconds: 4));
await shoot('07_Viewer');
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump(const Duration(seconds: 1));
// Back to the root folder, list view.
await _tapInTab(tester, filesTab, find.byTooltip('List view'));
await files.navigateToAbsoluteFolder('/');
await tester.pump(const Duration(seconds: 2));
// ---- 03 Photos ------------------------------------------------------
await _goToTab(tester, AppTab.photos);
await tester.pump(const Duration(seconds: 5)); // grid + thumbnails
await shoot('03_Photos');
// ---- 04 Favorites ---------------------------------------------------
await _goToTab(tester, AppTab.favorites);
await _pumpUntil(tester, find.text('Project brief.pdf'));
await shoot('04_Favorites');
// ---- 05 Activity ----------------------------------------------------
await _goToTab(tester, AppTab.activity);
await tester.pump(const Duration(seconds: 3));
await shoot('05_Activity');
// ---- 06 Shares ------------------------------------------------------
await _goToTab(tester, AppTab.shares);
await tester.pump(const Duration(seconds: 3));
await shoot('06_Shares');
// ---- 08 Sync status panel ------------------------------------------
// Sync two folders to the device; the header chip flips to "Synced"
// once the native worker has finished, and tapping it opens the panel.
await _goToTab(tester, AppTab.files);
await _read<SyncStatusController>(
tester,
).addSyncedPaths({'/Documents': true, '/Trips': true});
await _pumpUntil(tester, find.text('Synced'), timeout: 180);
await _tapInTab(tester, filesTab, find.text('Synced'));
await tester.pump(const Duration(seconds: 1));
await shoot('08_Sync_Status');
await _tapInTab(tester, filesTab, find.byTooltip('Collapse'));
// ---- 09 Offline -----------------------------------------------------
_setVisibleTabs(tester, [
AppTab.files,
AppTab.photos,
AppTab.favorites,
AppTab.shares,
AppTab.offline,
]);
await tester.pump(const Duration(milliseconds: 500));
await _goToTab(tester, AppTab.offline);
await _pumpUntil(
tester,
find.descendant(
of: find.byKey(const ValueKey('offline')),
matching: find.text('Documents'),
),
timeout: 120,
);
await shoot('09_Offline');
// ---- 10/11 Settings -------------------------------------------------
await _tap(tester, find.byTooltip('Settings').hitTestable().first);
await _pumpUntil(tester, find.text('Accounts'));
await shoot('10_Settings');
// Wait for it, rather than assuming it's already built - ensureVisible
// itself has no retry/timeout, so on any transient delay (e.g. still
// mid-route-transition) it fails immediately instead of giving the
// frame a moment to catch up.
await _pumpUntil(tester, find.text('Files Cache'));
await tester.ensureVisible(find.text('Files Cache'));
await tester.pump(const Duration(milliseconds: 300));
await shoot('11_Settings_Sync');
tester.state<NavigatorState>(find.byType(Navigator).first).pop();
await tester.pump(const Duration(seconds: 1));
// ---- 12/13 Dark theme -------------------------------------------------
final settings = _read<SettingsController>(tester);
settings.setThemeMode(ThemeMode.dark);
await _goToTab(tester, AppTab.files);
await tester.pump(const Duration(seconds: 2));
await shoot('12_Files_Dark');
await _goToTab(tester, AppTab.photos);
await tester.pump(const Duration(seconds: 3));
await shoot('13_Photos_Dark');
// ---- 14/15 Dark theme, AMOLED (pure black) -----------------------------
settings.setAmoledDark(true);
await tester.pump(const Duration(seconds: 1));
await _goToTab(tester, AppTab.files);
await tester.pump(const Duration(seconds: 2));
await shoot('14_Files_Dark_Amoled');
await _goToTab(tester, AppTab.photos);
await tester.pump(const Duration(seconds: 3));
await shoot('15_Photos_Dark_Amoled');
},
timeout: const Timeout(Duration(minutes: 15)),
);
}
/// Starts from empty storage, then writes exactly one saved account (the
/// demo user, with the app password from `--dart-define`) and the settings
/// that make screenshots reproducible: light theme, a fixed brand colour
/// (dynamic colour would pick up the emulator's wallpaper), five bottom-nav
/// tabs, and no first-run notification prompt.
Future<void> _seedLoggedInStorage() async {
await const FlutterSecureStorage().deleteAll();
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
final store = AccountStore();
final account = SavedAccount(
id: SavedAccount.makeId(_serverUrl, _username),
serverUrl: _serverUrl,
username: _username,
);
await store.saveAccounts(prefs, [account]);
await store.saveActiveAccountId(prefs, account.id);
await store.writePassword(account.id, _appPassword);
await prefs.setBool('notification_permission_asked', true);
await prefs.setString('ui_theme_mode', ThemeMode.light.name);
await prefs.setBool('ui_use_dynamic_color', false);
await prefs.setStringList('ui_tab_order', [
for (final t in AppTab.values) t.name,
]);
await prefs.setStringList('ui_hidden_tabs', [
AppTab.trash.name,
AppTab.recent.name,
AppTab.offline.name,
]);
}
T _read<T>(WidgetTester tester) => Provider.of<T>(
tester.element(find.byType(app.MainShellView)),
listen: false,
);
Future<void> _tap(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pump(const Duration(milliseconds: 600));
}
/// Taps the one widget matching [matching] inside [tab] (e.g. `filesTab`) -
/// for chrome that's duplicated across tabs (`IndexedStack` keeps every
/// visible tab's widget tree mounted, not just the active one) and/or can be
/// scrolled out of its own row's clipped viewport (the controls row,
/// `FilesControlsRow`, doesn't fit every control on a narrow screen).
/// Scoping to [tab] picks the right one of several identical matches;
/// `ensureVisible` scrolls it into view before tapping, which plain
/// `.hitTestable()` can't distinguish from "on the wrong tab" - both look
/// like "zero hit-testable matches" and throw the same unhelpful
/// `Bad state: No element`.
Future<void> _tapInTab(WidgetTester tester, Finder tab, Finder matching) async {
final target = find.descendant(of: tab, matching: matching).first;
await tester.ensureVisible(target);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(target, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 600));
}
Future<void> _goToTab(WidgetTester tester, AppTab tab) async {
// Only one bottom nav bar exists (outside the IndexedStack), so no
// cross-tab duplicate to worry about - but it can itself overflow a
// narrow screen, so still scroll the target into view rather than relying
// on hitTestable() alone (see `_tapInTab`'s doc comment).
final target = find
.descendant(
of: find.byType(BottomNavBar),
matching: find.byIcon(tab.icon),
)
.first;
await tester.ensureVisible(target);
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(target, warnIfMissed: false);
await tester.pump(const Duration(milliseconds: 600));
// Confirm the tap actually landed, rather than trusting it silently - a
// scroll-imprecise tap (see above) that misses its target doesn't throw,
// it just does nothing, which would otherwise leave every later
// screenshot quietly showing whatever tab was already selected instead of
// failing loudly. Every destination shows its label all the time now (see
// `BottomNavBar`'s doc comment), so label presence can't signal selection
// - the icon's color can: it's `accentText` only for the active tab.
await _pumpUntilTrue(tester, () {
if (target.evaluate().isEmpty) return false;
final icon = tester.widget<Icon>(target);
final colors = tester.element(target).nooColors;
return icon.color == colors.accentText;
}, timeout: 10);
}
/// Shows exactly [tabs] in the bottom nav (hiding the rest first, so the
/// five-tab cap is never hit).
void _setVisibleTabs(WidgetTester tester, List<AppTab> tabs) {
final settings = _read<SettingsController>(tester);
for (final tab in AppTab.values) {
if (!tabs.contains(tab)) settings.setTabHidden(tab, true);
}
for (final tab in tabs) {
settings.setTabHidden(tab, false);
}
}
/// Waits (in real time - this is a live binding, and the app is talking to a
/// real server) until [finder] matches something.
Future<void> _pumpUntil(
WidgetTester tester,
Finder finder, {
int timeout = 60,
}) async {
final deadline = DateTime.now().add(Duration(seconds: timeout));
while (finder.evaluate().isEmpty) {
if (DateTime.now().isAfter(deadline)) {
throw TestFailure('Timed out after ${timeout}s waiting for $finder');
}
await tester.pump(const Duration(milliseconds: 250));
}
}
/// Like [_pumpUntil], but for a condition that isn't just "does a finder
/// match something" (e.g. a widget property).
Future<void> _pumpUntilTrue(
WidgetTester tester,
bool Function() predicate, {
int timeout = 60,
}) async {
final deadline = DateTime.now().add(Duration(seconds: timeout));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
throw TestFailure('Timed out after ${timeout}s waiting for condition');
}
await tester.pump(const Duration(milliseconds: 250));
}
}
+17 -3
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -44,6 +45,8 @@ import 'widgets/shell/shell_common.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Fonts ship in assets/google_fonts; never fetch them from Google at runtime.
GoogleFonts.config.allowRuntimeFetching = false;
runApp( runApp(
MultiProvider( MultiProvider(
// Split out of the former single `ServerProvider` god object - see // Split out of the former single `ServerProvider` god object - see
@@ -441,7 +444,10 @@ class _MainShellViewState extends State<MainShellView> {
if (isDesktop) { if (isDesktop) {
scaffold = Scaffold( scaffold = Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
body: Row( // Tablets keep the system bars, so inset for them (desktop's
// insets are zero).
body: SafeArea(
child: Row(
children: [ children: [
NooSidebar( NooSidebar(
account: NooSidebarAccount( account: NooSidebarAccount(
@@ -463,7 +469,6 @@ class _MainShellViewState extends State<MainShellView> {
selected: tab == selectedTab, selected: tab == selectedTab,
onTap: () => selectTab(tab), onTap: () => selectTab(tab),
), ),
if (hiddenTabs.isNotEmpty) const NooSidebarDivider(),
for (final tab in hiddenTabs) for (final tab in hiddenTabs)
NooSidebarItem( NooSidebarItem(
icon: tab.icon, icon: tab.icon,
@@ -506,6 +511,7 @@ class _MainShellViewState extends State<MainShellView> {
), ),
], ],
), ),
),
); );
} else { } else {
final bottomBarStyle = settings.bottomBarStyle; final bottomBarStyle = settings.bottomBarStyle;
@@ -514,6 +520,10 @@ class _MainShellViewState extends State<MainShellView> {
drawerScrimColor: colors.scrim, drawerScrimColor: colors.scrim,
// The top bar itself no longer lives here - see `tabStack` above. // The top bar itself no longer lives here - see `tabStack` above.
drawer: pickRequest == null ? const AppDrawer() : null, drawer: pickRequest == null ? const AppDrawer() : null,
// The avatar menu replaces the drawer, so the edge swipe must not
// open it either.
drawerEnableOpenDragGesture:
settings.navMenuStyle == NooNavMenuStyle.drawer,
// Floating needs the body to draw behind the bar's own transparent // Floating needs the body to draw behind the bar's own transparent
// margin (see NooBottomBarStyle's doc comment) instead of stopping // margin (see NooBottomBarStyle's doc comment) instead of stopping
// short of it like attached does. // short of it like attached does.
@@ -526,7 +536,11 @@ class _MainShellViewState extends State<MainShellView> {
floatingActionButton: floatingActionButton:
pickRequest == null && navStyle == NooNavStyle.android pickRequest == null && navStyle == NooNavStyle.android
? NooFab( ? NooFab(
collapsed: !canUpload, collapsed: switch (settings.fabStyle) {
FabStyle.auto => !canUpload,
FabStyle.mini => true,
FabStyle.expanded => false,
},
barStyle: bottomBarStyle, barStyle: bottomBarStyle,
onTap: () => showCreateMenu(context), onTap: () => showCreateMenu(context),
) )
+16
View File
@@ -39,4 +39,20 @@ class NextcloudShare {
}); });
bool get isFolder => itemType == NextcloudItemType.folder; bool get isFolder => itemType == NextcloudItemType.folder;
NextcloudShare withPermissions(int permissions) => NextcloudShare(
id: id,
path: path,
name: name,
itemType: itemType,
shareType: shareType,
ownerDisplayName: ownerDisplayName,
sharedWithDisplayName: sharedWithDisplayName,
sharedAt: sharedAt,
sharedWithMe: sharedWithMe,
url: url,
permissions: permissions,
token: token,
expireDate: expireDate,
);
} }
+12 -1
View File
@@ -101,6 +101,7 @@ class FilesController extends ChangeNotifier
NextcloudUserQuota? _quota; NextcloudUserQuota? _quota;
List<NextcloudActivity> _activities = []; List<NextcloudActivity> _activities = [];
bool _isLoading = false; bool _isLoading = false;
bool _activating = false;
String? _errorMessage; String? _errorMessage;
FilesController(this.session) { FilesController(this.session) {
@@ -147,7 +148,10 @@ class FilesController extends ChangeNotifier
CachePolicy get cachePolicy => _cachePolicy; CachePolicy get cachePolicy => _cachePolicy;
int get cacheIntervalMinutes => _cacheIntervalMinutes; int get cacheIntervalMinutes => _cacheIntervalMinutes;
@override @override
bool get isLoading => _isLoading; // Also true while a freshly activated account is still restoring prefs
// and while the session verifies it, so a switch shows a spinner instead
// of briefly flashing the empty-folder message.
bool get isLoading => _isLoading || _activating || session.isLoading;
@override @override
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
NextcloudUserQuota? get quota => _quota; NextcloudUserQuota? get quota => _quota;
@@ -360,9 +364,16 @@ class FilesController extends ChangeNotifier
} }
Future<void> _onAccountActivated() async { Future<void> _onAccountActivated() async {
_activating = true;
notifyListeners();
try {
await _restoreDisplayPrefs(); await _restoreDisplayPrefs();
_startCacheRefreshTimerIfNeeded(); _startCacheRefreshTimerIfNeeded();
await refreshData(); await refreshData();
} finally {
_activating = false;
notifyListeners();
}
} }
@override @override
+17 -3
View File
@@ -168,9 +168,9 @@ class ItemOperations {
(c) => choices[c.item.id] == ConflictChoice.keepBoth, (c) => choices[c.item.id] == ConflictChoice.keepBoth,
); );
final existingNames = needsKeepBoth final existingNames = needsKeepBoth
? (await files.fetchFolderListing(destFolderPath)) ? (await files.fetchFolderListing(
.map((i) => i.name) destFolderPath,
.toSet() )).map((i) => i.name).toSet()
: <String>{}; : <String>{};
var succeeded = 0; var succeeded = 0;
@@ -303,6 +303,20 @@ class ItemOperations {
} }
} }
Future<bool> updateSharePermissions(
NextcloudShare share,
int permissions,
) async {
final service = session.service;
if (service == null) return false;
try {
return await service.updateSharePermissions(share.id, permissions);
} catch (e) {
debugPrint('[ItemOperations] updateSharePermissions failed: $e');
return false;
}
}
/// Deletes a share (from the file-details/Share sheet, not the Shares /// Deletes a share (from the file-details/Share sheet, not the Shares
/// tab itself - see `SharesController.deleteShare` for that one, which /// tab itself - see `SharesController.deleteShare` for that one, which
/// additionally patches the tab's own list). /// additionally patches the tab's own list).
+23
View File
@@ -9,6 +9,11 @@ import '../widgets/noo/nav/noo_nav_style.dart';
/// in Settings. /// in Settings.
enum SwipeAction { none, favorite, delete, share } enum SwipeAction { none, favorite, delete, share }
/// How the Android Upload FAB is sized: [auto] expands on Files/Photos and
/// shrinks to an icon elsewhere, [mini] is always icon-only, [expanded]
/// always shows the label.
enum FabStyle { auto, mini, expanded }
/// Thumb/track presets for the video player's seek bar, matching the four /// Thumb/track presets for the video player's seek bar, matching the four
/// combinations offered by other Material You media players: a Material 3 /// combinations offered by other Material You media players: a Material 3
/// slider-style thumb ([classic]), an animated travelling wave with a round /// slider-style thumb ([classic]), an animated travelling wave with a round
@@ -31,6 +36,7 @@ class SettingsController extends ChangeNotifier {
static const _prefDefaultTab = 'ui_default_tab'; static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action'; static const _prefSwipeLeftAction = 'ui_swipe_left_action';
static const _prefSwipeRightAction = 'ui_swipe_right_action'; static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefFabStyle = 'ui_fab_style';
static const _prefBottomBarStyle = 'ui_bottom_bar_style'; static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefNavMenuStyle = 'ui_nav_menu_style'; static const _prefNavMenuStyle = 'ui_nav_menu_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar'; static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
@@ -61,6 +67,7 @@ class SettingsController extends ChangeNotifier {
SwipeAction _swipeLeftAction = SwipeAction.delete; SwipeAction _swipeLeftAction = SwipeAction.delete;
SwipeAction _swipeRightAction = SwipeAction.favorite; SwipeAction _swipeRightAction = SwipeAction.favorite;
FabStyle _fabStyle = FabStyle.auto;
// A one-shot request for the shell to switch its active bottom-nav tab // A one-shot request for the shell to switch its active bottom-nav tab
// (e.g. a search result landing on Files) - consumed and cleared by // (e.g. a search result landing on Files) - consumed and cleared by
@@ -121,6 +128,7 @@ class SettingsController extends ChangeNotifier {
SwipeAction get swipeLeftAction => _swipeLeftAction; SwipeAction get swipeLeftAction => _swipeLeftAction;
SwipeAction get swipeRightAction => _swipeRightAction; SwipeAction get swipeRightAction => _swipeRightAction;
FabStyle get fabStyle => _fabStyle;
Future<void> _load() async { Future<void> _load() async {
try { try {
@@ -242,6 +250,14 @@ class SettingsController extends ChangeNotifier {
); );
} }
final fabName = prefs.getString(_prefFabStyle);
if (fabName != null) {
_fabStyle = FabStyle.values.firstWhere(
(f) => f.name == fabName,
orElse: () => _fabStyle,
);
}
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('[SettingsController] Preference restore failed: $e'); debugPrint('[SettingsController] Preference restore failed: $e');
@@ -404,4 +420,11 @@ class SettingsController extends ChangeNotifier {
notifyListeners(); notifyListeners();
_prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name)); _prefsFuture.then((p) => p.setString(_prefSwipeRightAction, action.name));
} }
void setFabStyle(FabStyle style) {
if (_fabStyle == style) return;
_fabStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefFabStyle, style.name));
}
} }
+29
View File
@@ -147,6 +147,19 @@ class NextcloudService {
return response.bodyBytes; return response.bodyBytes;
} }
/// Overwrites the file at [itemPath] with [bytes] (WebDAV PUT) - used by
/// the in-app text editor. Returns whether the server accepted it.
Future<bool> putBytes(String itemPath, List<int> bytes) async {
final response = await http.put(
Uri.parse(fileUrl(itemPath)),
headers: _headers,
body: bytes,
);
return response.statusCode == 200 ||
response.statusCode == 201 ||
response.statusCode == 204;
}
Future<bool> testConnection() async { Future<bool> testConnection() async {
try { try {
final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/'; final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/';
@@ -1525,6 +1538,22 @@ class NextcloudService {
return response.statusCode == 200; return response.statusCode == 200;
} }
/// Changes a share's permission bitmask (1 read, 2 update, 4 create,
/// 8 delete, 16 reshare).
Future<bool> updateSharePermissions(String shareId, int permissions) async {
final url =
'$_cleanServerUrl/ocs/v2.php/apps/files_sharing/api/v1/shares/$shareId?format=json';
debugPrint('[Nextcloud OCS] Updating share $shareId permissions');
final response = await http.put(
Uri.parse(url),
headers: _headers,
body: {'permissions': '$permissions'},
);
debugPrint('[Nextcloud OCS] Update share status: ${response.statusCode}');
return response.statusCode == 200;
}
NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) { NextcloudShare _shareFromJson(Map raw, {required bool sharedWithMe}) {
final path = (raw['path'] ?? '/').toString(); final path = (raw['path'] ?? '/').toString();
final segments = path.split('/').where((s) => s.isNotEmpty).toList(); final segments = path.split('/').where((s) => s.isNotEmpty).toList();
+37 -64
View File
@@ -20,11 +20,8 @@ import '../widgets/settings/settings_tabs.dart';
/// order: account card, accounts, security, file sync, files cache, /// order: account card, accounts, security, file sync, files cache,
/// appearance, tabs, action bar, swipe on a file). /// appearance, tabs, action bar, swipe on a file).
/// ///
/// Desktop is unchanged: a 2-column grid of cards ([_DesktopGrid]) wide /// Every layout (including wide tablet, in a centered 640px column) is
/// enough to see every section at once, so it has no scroll-depth problem /// a two-level menu, the way native iOS/Android Settings apps
/// and needs no menu.
///
/// Mobile is a two-level menu, the way native iOS/Android Settings apps
/// work: [SettingsAccountCard] (the account summary, not a settings picker) /// work: [SettingsAccountCard] (the account summary, not a settings picker)
/// stays pinned at the top of a single top-level list ([_MobileMenu]), and /// stays pinned at the top of a single top-level list ([_MobileMenu]), and
/// every other section becomes one tappable [NooSettingsRow] - icon, title, /// every other section becomes one tappable [NooSettingsRow] - icon, title,
@@ -47,18 +44,6 @@ import '../widgets/settings/settings_tabs.dart';
class AccountView extends StatelessWidget { class AccountView extends StatelessWidget {
const AccountView({super.key}); const AccountView({super.key});
static const _sections = <Widget>[
SettingsAccountCard(),
SettingsAccountsSection(),
SettingsSecuritySection(),
SettingsFileSyncSection(),
SettingsFilesCacheSection(),
SettingsAppearanceSection(),
SettingsTabsSection(),
SettingsActionBarSection(),
SettingsSwipeSection(),
];
static final _categories = <_SettingsCategory>[ static final _categories = <_SettingsCategory>[
_SettingsCategory( _SettingsCategory(
title: 'Accounts', title: 'Accounts',
@@ -110,7 +95,7 @@ class AccountView extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
appBar: desktop appBar: desktop
? const NooToolbar(title: 'Settings') ? _desktopBar(context, 'Settings')
: NooTopBar( : NooTopBar(
style: NooLayout.navStyle(context), style: NooLayout.navStyle(context),
title: 'Settings', title: 'Settings',
@@ -119,7 +104,12 @@ class AccountView extends StatelessWidget {
body: SafeArea( body: SafeArea(
top: false, top: false,
child: desktop child: desktop
? _DesktopGrid(sections: _sections) ? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: _MobileMenu(categories: _categories),
),
)
: _MobileMenu(categories: _categories), : _MobileMenu(categories: _categories),
), ),
); );
@@ -207,16 +197,24 @@ class _SettingsCategoryScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final desktop = NooLayout.isDesktop(context);
return Scaffold( return Scaffold(
backgroundColor: colors.bg, backgroundColor: colors.bg,
appBar: NooTopBar( appBar: desktop
? _desktopBar(context, category.title)
: NooTopBar(
style: NooLayout.navStyle(context), style: NooLayout.navStyle(context),
title: category.title, title: category.title,
leading: const NooTopBarBack(), leading: const NooTopBarBack(),
), ),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: desktop ? 640 : double.infinity,
),
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
NooSpace.sm, NooSpace.sm,
@@ -228,57 +226,32 @@ class _SettingsCategoryScreen extends StatelessWidget {
children: [category.builder(context)], children: [category.builder(context)],
), ),
), ),
);
}
}
/// Splits the sections between two columns rather than a strict grid, since
/// each card's content height varies a lot (the tab reorder list and the
/// accounts list can both run much taller than, say, Security) - a fixed
/// grid would either clip content or leave large gaps.
class _DesktopGrid extends StatelessWidget {
final List<Widget> sections;
const _DesktopGrid({required this.sections});
@override
Widget build(BuildContext context) {
final left = <Widget>[];
final right = <Widget>[];
for (var i = 0; i < sections.length; i++) {
(i.isEven ? left : right).add(sections[i]);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(NooSpace.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1040),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _Column(children: left)),
const SizedBox(width: NooSpace.xl),
Expanded(child: _Column(children: right)),
],
), ),
), ),
); );
} }
} }
class _Column extends StatelessWidget { PreferredSizeWidget _desktopBar(BuildContext context, String title) {
final List<Widget> children; final top = MediaQuery.paddingOf(context).top;
const _Column({required this.children}); return PreferredSize(
preferredSize: Size.fromHeight(NooToolbar.outerHeight + top),
@override child: Padding(
Widget build(BuildContext context) { padding: EdgeInsets.only(top: top),
return Column( child: NooToolbar(
crossAxisAlignment: CrossAxisAlignment.start, titleWidget: Row(
mainAxisSize: MainAxisSize.min,
spacing: NooSpace.sm,
children: [ children: [
for (final child in children) ...[ IconButton(
child, icon: const Icon(LucideIcons.arrowLeft),
const SizedBox(height: NooSpace.xl), tooltip: 'Back',
], onPressed: () => Navigator.maybePop(context),
),
Text(title),
], ],
),
),
),
); );
}
} }
+3 -1
View File
@@ -586,7 +586,9 @@ class _FavoritesViewState extends State<FavoritesView> {
), ),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2, crossAxisCount: isDesktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
crossAxisSpacing: isDesktop ? 16 : 10, crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10, mainAxisSpacing: isDesktop ? 16 : 10,
childAspectRatio: isDesktop ? 0.92 : 0.85, childAspectRatio: isDesktop ? 0.92 : 0.85,
+67 -4
View File
@@ -21,6 +21,7 @@ import '../widgets/noo/overlays/noo_overlay_header.dart';
import '../widgets/noo/overlays/noo_sheet.dart'; import '../widgets/noo/overlays/noo_sheet.dart';
import '../widgets/share_sheet.dart'; import '../widgets/share_sheet.dart';
import '../widgets/viewer/media_action_bar.dart'; import '../widgets/viewer/media_action_bar.dart';
import '../widgets/viewer/media_details_panel.dart';
import '../widgets/viewer/media_image_preview.dart'; import '../widgets/viewer/media_image_preview.dart';
import '../widgets/viewer/media_pdf_preview.dart'; import '../widgets/viewer/media_pdf_preview.dart';
import '../widgets/viewer/media_text_preview.dart'; import '../widgets/viewer/media_text_preview.dart';
@@ -154,6 +155,43 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
/// also drag the gallery to the next item. /// also drag the gallery to the next item.
bool _isZoomed = false; bool _isZoomed = false;
final Map<int, Offset> _pointerStart = {};
bool _multiTouch = false;
void _onPointerDown(PointerDownEvent e) {
if (_pointerStart.isNotEmpty) _multiTouch = true;
_pointerStart[e.pointer] = e.position;
}
// Raw pointer events rather than a drag recognizer, so this never competes
// with the PageView / InteractiveViewer gestures in the arena.
void _onPointerUp(PointerUpEvent e) {
final start = _pointerStart.remove(e.pointer);
final wasMulti = _multiTouch;
if (_pointerStart.isEmpty) _multiTouch = false;
if (start == null || wasMulti || _isZoomed || !_isSwipeable) return;
final delta = e.position - start;
if (delta.dy < -80 && delta.dy.abs() > delta.dx.abs() * 1.5) {
_openDetails();
}
}
final _panelKey = GlobalKey<MediaDetailsPanelState>();
void _openDetails() {
if (NooLayout.isDesktop(context)) {
DetailsSheet.show(context, _currentItem);
return;
}
setState(() => _controlsVisible = true);
_panelKey.currentState?.expand();
}
void _onPointerCancel(PointerCancelEvent e) {
_pointerStart.remove(e.pointer);
if (_pointerStart.isEmpty) _multiTouch = false;
}
NextcloudItem get _currentItem => _mediaItems[_currentIndex]; NextcloudItem get _currentItem => _mediaItems[_currentIndex];
bool get _isOffline => widget.localPathResolver != null; bool get _isOffline => widget.localPathResolver != null;
@@ -394,15 +432,27 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
backgroundColor: stageColor, backgroundColor: stageColor,
body: GestureDetector( body: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _controlsVisible = !_controlsVisible), onTap: () {
final panel = _panelKey.currentState;
if (panel != null && panel.isExpanded) {
panel.collapse();
return;
}
setState(() => _controlsVisible = !_controlsVisible);
},
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( Positioned.fill(
child: Listener(
onPointerDown: _onPointerDown,
onPointerUp: _onPointerUp,
onPointerCancel: _onPointerCancel,
child: Container( child: Container(
color: stageColor, color: stageColor,
child: _buildBody(context, session), child: _buildBody(context, session),
), ),
), ),
),
if (_downloadProgress != null) if (_downloadProgress != null)
Positioned( Positioned(
left: 0, left: 0,
@@ -522,7 +572,15 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4), offset: _controlsVisible ? Offset.zero : const Offset(0, 1.4),
child: IgnorePointer( child: IgnorePointer(
ignoring: !_controlsVisible, ignoring: !_controlsVisible,
child: MediaActionBar( child: MediaDetailsPanel(
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, isFavorite: _currentItem.isFavorite,
isBusy: _isBusy, isBusy: _isBusy,
// Favorite/delete/download-to-device all need a live // Favorite/delete/download-to-device all need a live
@@ -533,14 +591,16 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
_isSwipeable && _isSwipeable &&
_currentItem.type == NextcloudItemType.video && _currentItem.type == NextcloudItemType.video &&
_videoController != null _videoController != null
? VideoTransportControls(controller: _videoController!) ? VideoTransportControls(
controller: _videoController!,
)
: null, : null,
onShare: () => ShareSheet.show(context, _currentItem), onShare: () => ShareSheet.show(context, _currentItem),
onFavorite: () => _toggleFavorite(ops), onFavorite: () => _toggleFavorite(ops),
onDelete: () => _deleteCurrentItem(ops), onDelete: () => _deleteCurrentItem(ops),
onOpenExternally: () => _openExternally(session), onOpenExternally: () => _openExternally(session),
onDownload: () => _downloadToDevice(session), onDownload: () => _downloadToDevice(session),
onDetails: () => DetailsSheet.show(context, _currentItem), ),
), ),
), ),
), ),
@@ -659,6 +719,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
default: default:
if (_isPdf) { if (_isPdf) {
return MediaPdfPreview( return MediaPdfPreview(
topInset: _controlsVisible
? MediaQuery.paddingOf(context).top + 60
: 0,
item: widget.item, item: widget.item,
session: session, session: session,
localPathResolver: widget.localPathResolver, localPathResolver: widget.localPathResolver,
+6 -1
View File
@@ -754,7 +754,9 @@ class _FilesViewState extends State<FilesView> {
), ),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isDesktop ? 5 : 2, crossAxisCount: isDesktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: isDesktop ? 1.05 : 0.92, childAspectRatio: isDesktop ? 1.05 : 0.92,
crossAxisSpacing: isDesktop ? 16 : 10, crossAxisSpacing: isDesktop ? 16 : 10,
mainAxisSpacing: isDesktop ? 16 : 10, mainAxisSpacing: isDesktop ? 16 : 10,
@@ -1323,6 +1325,9 @@ class _FilesViewState extends State<FilesView> {
selected: isSelected, selected: isSelected,
thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop), thumbnail: _rowThumbnail(context, item, session, NooFileTileSize.desktop),
onTap: () => _handleItemTap(context, item, picking: picking), onTap: () => _handleItemTap(context, item, picking: picking),
onLongPress: _offline || (picking && !pick.pickRequest!.allowMultiple)
? null
: () => _toggleSelection(item),
onMore: !_isSelecting && !picking onMore: !_isSelecting && !picking
? () => _showItemActions(context, item) ? () => _showItemActions(context, item)
: null, : null,
+3 -1
View File
@@ -284,7 +284,9 @@ class _MoveCopyDestinationPickerState extends State<MoveCopyDestinationPicker> {
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2, crossAxisCount: desktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: desktop ? 1.05 : 0.92, childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10, crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10, mainAxisSpacing: desktop ? 16 : 10,
+5 -1
View File
@@ -342,7 +342,11 @@ class _PhotosViewState extends State<PhotosView> {
itemCount: group.items.length, itemCount: group.items.length,
columns: isDesktop columns: isDesktop
? NooPhotoGrid.desktopColumns ? NooPhotoGrid.desktopColumns
: NooPhotoGrid.mobileColumns, : NooLayout.gridColumns(
context,
phone: NooPhotoGrid.mobileColumns,
minTile: 130,
),
gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap, gap: isDesktop ? NooPhotoGrid.desktopGap : NooPhotoGrid.mobileGap,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: isDesktop ? NooLayout.gutter(context) : 0, horizontal: isDesktop ? NooLayout.gutter(context) : 0,
+3 -1
View File
@@ -209,7 +209,9 @@ class _ShareUploadViewState extends State<ShareUploadView> {
padding: EdgeInsets.symmetric(horizontal: gutter), padding: EdgeInsets.symmetric(horizontal: gutter),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: desktop ? 5 : 2, crossAxisCount: desktop
? 5
: NooLayout.gridColumns(context, phone: 2, minTile: 180),
childAspectRatio: desktop ? 1.05 : 0.92, childAspectRatio: desktop ? 1.05 : 0.92,
crossAxisSpacing: desktop ? 16 : 10, crossAxisSpacing: desktop ? 16 : 10,
mainAxisSpacing: desktop ? 16 : 10, mainAxisSpacing: desktop ? 16 : 10,
+184 -2
View File
@@ -7,6 +7,7 @@ import '../providers/settings_controller.dart';
import '../providers/trash_controller.dart'; import '../providers/trash_controller.dart';
import '../theme/design_tokens.dart'; import '../theme/design_tokens.dart';
import 'noo/core/noo_avatar.dart'; import 'noo/core/noo_avatar.dart';
import '../views/login_view.dart';
import 'noo/core/noo_badge.dart'; import 'noo/core/noo_badge.dart';
import 'noo/lists/noo_settings_row.dart'; import 'noo/lists/noo_settings_row.dart';
import 'shell/shell_common.dart'; import 'shell/shell_common.dart';
@@ -43,9 +44,16 @@ Future<void> showAvatarMenu(BuildContext context) {
); );
} }
class _AvatarMenuContent extends StatelessWidget { class _AvatarMenuContent extends StatefulWidget {
const _AvatarMenuContent(); const _AvatarMenuContent();
@override
State<_AvatarMenuContent> createState() => _AvatarMenuContentState();
}
class _AvatarMenuContentState extends State<_AvatarMenuContent> {
bool _expanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -124,7 +132,7 @@ class _AvatarMenuContent extends StatelessWidget {
// header reads as a continuation of the button that // header reads as a continuation of the button that
// opened it rather than a disconnected card. // opened it rather than a disconnected card.
InkWell( InkWell(
onTap: closeAndOpenSettings, onTap: () => setState(() => _expanded = !_expanded),
child: Padding( child: Padding(
padding: const EdgeInsets.all(NooSpace.md), padding: const EdgeInsets.all(NooSpace.md),
child: Row( child: Row(
@@ -161,10 +169,87 @@ class _AvatarMenuContent extends StatelessWidget {
current: true, current: true,
size: 40, 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) ...[ if (hiddenTabs.isNotEmpty) ...[
Divider(height: 1, color: colors.line), Divider(height: 1, color: colors.line),
for (final tab in hiddenTabs) 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),
),
),
],
),
),
),
);
}
}
+7 -7
View File
@@ -54,7 +54,7 @@ enum _DetailsTab { info, versions, activity }
/// and the "open externally" flow for unsupported file types. /// and the "open externally" flow for unsupported file types.
/// ///
/// Only used directly on mobile (bundles its own [NooOverlayHeader]); the /// Only used directly on mobile (bundles its own [NooOverlayHeader]); the
/// desktop path in [show] passes [_DetailsBody] straight to [showNooDialog], /// desktop path in [show] passes [DetailsBody] straight to [showNooDialog],
/// which renders the header itself via `leading`/`title`/`subtitle`. /// which renders the header itself via `leading`/`title`/`subtitle`.
class DetailsSheet extends StatelessWidget { class DetailsSheet extends StatelessWidget {
final NextcloudItem item; final NextcloudItem item;
@@ -68,7 +68,7 @@ class DetailsSheet extends StatelessWidget {
leading: detailsFileTile(item), leading: detailsFileTile(item),
title: item.name, title: item.name,
subtitle: detailsMetaLine(item), subtitle: detailsMetaLine(item),
children: [_DetailsBody(item: item)], children: [DetailsBody(item: item)],
); );
} }
return showNooSheet(context, children: [DetailsSheet(item: item)]); return showNooSheet(context, children: [DetailsSheet(item: item)]);
@@ -86,7 +86,7 @@ class DetailsSheet extends StatelessWidget {
onClose: () => Navigator.pop(context), onClose: () => Navigator.pop(context),
), ),
const SizedBox(height: NooSpace.lg), const SizedBox(height: NooSpace.lg),
_DetailsBody(item: item), DetailsBody(item: item),
], ],
); );
} }
@@ -102,16 +102,16 @@ class DetailsSheet extends StatelessWidget {
/// guard), so switching tabs via a bare `switch` on the selected type would /// guard), so switching tabs via a bare `switch` on the selected type would
/// tear down and rebuild whichever tab isn't showing, discarding its /// tear down and rebuild whichever tab isn't showing, discarding its
/// fetched data and re-requesting it every time the user switched back. /// fetched data and re-requesting it every time the user switched back.
class _DetailsBody extends StatefulWidget { class DetailsBody extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
const _DetailsBody({required this.item}); const DetailsBody({super.key, required this.item});
@override @override
State<_DetailsBody> createState() => _DetailsBodyState(); State<DetailsBody> createState() => _DetailsBodyState();
} }
class _DetailsBodyState extends State<_DetailsBody> { class _DetailsBodyState extends State<DetailsBody> {
_DetailsTab _tab = _DetailsTab.info; _DetailsTab _tab = _DetailsTab.info;
@override @override
+7 -2
View File
@@ -17,6 +17,9 @@ class FrostedGlassContainer extends StatelessWidget {
final Widget child; final Widget child;
final double borderRadius; final double borderRadius;
/// Overrides [borderRadius] for non-uniform corners (e.g. top-only).
final BorderRadius? radius;
/// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an /// Blur sigma for the backdrop filter. Defaults to a fixed value; pass an
/// explicit value (e.g. from user settings) to make it adjustable. /// explicit value (e.g. from user settings) to make it adjustable.
final double blurSigma; final double blurSigma;
@@ -35,6 +38,7 @@ class FrostedGlassContainer extends StatelessWidget {
super.key, super.key,
required this.child, required this.child,
this.borderRadius = 0, this.borderRadius = 0,
this.radius,
this.blurSigma = 20, this.blurSigma = 20,
this.opacity = 0.8, this.opacity = 0.8,
this.color, this.color,
@@ -42,15 +46,16 @@ class FrostedGlassContainer extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final shape = radius ?? BorderRadius.circular(borderRadius);
final tint = color ?? context.nooColors.surface; final tint = color ?? context.nooColors.surface;
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius), borderRadius: shape,
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma), filter: ImageFilter.blur(sigmaX: blurSigma, sigmaY: blurSigma),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: tint.withValues(alpha: opacity), color: tint.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(borderRadius), borderRadius: shape,
), ),
child: child, child: child,
), ),
@@ -154,6 +154,7 @@ class NooFileTableRow extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onDoubleTap; final VoidCallback? onDoubleTap;
final VoidCallback? onSecondaryTap; final VoidCallback? onSecondaryTap;
final VoidCallback? onLongPress;
/// Overflow menu tap. The button is hidden when null. /// Overflow menu tap. The button is hidden when null.
final VoidCallback? onMore; final VoidCallback? onMore;
@@ -171,6 +172,7 @@ class NooFileTableRow extends StatelessWidget {
this.onTap, this.onTap,
this.onDoubleTap, this.onDoubleTap,
this.onSecondaryTap, this.onSecondaryTap,
this.onLongPress,
this.onMore, this.onMore,
}); });
@@ -193,6 +195,7 @@ class NooFileTableRow extends StatelessWidget {
onTap: onTap, onTap: onTap,
onDoubleTap: onDoubleTap, onDoubleTap: onDoubleTap,
onSecondaryTap: onSecondaryTap, onSecondaryTap: onSecondaryTap,
onLongPress: onLongPress,
child: SizedBox( child: SizedBox(
height: NooSizes.rowDesktop, height: NooSizes.rowDesktop,
child: _TableColumns( child: _TableColumns(
@@ -22,6 +22,9 @@ class NooGroupedList extends StatefulWidget {
final String? aside; final String? aside;
final List<Widget> children; final List<Widget> children;
final Widget? footer; final Widget? footer;
/// Shown between [label] and the card (e.g. a `NooInfoNote`).
final Widget? notice;
final bool collapsible; final bool collapsible;
final bool initiallyExpanded; final bool initiallyExpanded;
@@ -31,6 +34,7 @@ class NooGroupedList extends StatefulWidget {
this.aside, this.aside,
required this.children, required this.children,
this.footer, this.footer,
this.notice,
this.collapsible = false, this.collapsible = false,
this.initiallyExpanded = true, this.initiallyExpanded = true,
}); });
@@ -58,6 +62,10 @@ class _NooGroupedListState extends State<NooGroupedList> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
if (widget.notice != null) ...[
widget.notice!,
const SizedBox(height: NooSpace.md),
],
AnimatedCrossFade( AnimatedCrossFade(
duration: NooMotion.fast, duration: NooMotion.fast,
sizeCurve: NooMotion.ease, sizeCurve: NooMotion.ease,
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../theme/design_tokens.dart';
/// Tinted info callout (info-soft fill, info icon) for a short notice that
/// must be noticed immediately, unlike a muted footer/subtitle line.
class NooInfoNote extends StatelessWidget {
final String message;
const NooInfoNote({super.key, required this.message});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: colors.infoSoft,
borderRadius: BorderRadius.circular(NooRadii.input),
border: Border.all(color: colors.info.withValues(alpha: 0.35)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(LucideIcons.info, size: 18, color: colors.info),
),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: NooText.body.copyWith(color: colors.fg1, height: 1.35),
),
),
],
),
);
}
}
+15 -5
View File
@@ -48,21 +48,30 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
); );
@override @override
Size get preferredSize => const Size.fromHeight(NooSizes.toolbar); Size get preferredSize => const Size.fromHeight(outerHeight);
static const _margin = EdgeInsets.fromLTRB(12, 8, 12, 4);
/// Bar height plus the margin that makes it float.
static const double outerHeight = NooSizes.toolbar + 12;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1); final titleStyle = NooText.title.copyWith(height: 1, color: colors.fg1);
return Material( return Padding(
padding: _margin,
child: Material(
color: backgroundColor ?? colors.surface, color: backgroundColor ?? colors.surface,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NooRadii.card),
side: BorderSide(color: colors.line),
),
child: Container( child: Container(
height: NooSizes.toolbar, height: NooSizes.toolbar,
padding: const EdgeInsetsDirectional.only(start: 24, end: 20), padding: const EdgeInsetsDirectional.only(start: 24, end: 20),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: colors.line)),
),
child: Row( child: Row(
spacing: 12, spacing: 12,
children: [ children: [
@@ -102,6 +111,7 @@ class NooToolbar extends StatelessWidget implements PreferredSizeWidget {
], ],
), ),
), ),
),
); );
} }
} }
+1
View File
@@ -206,6 +206,7 @@ class NooTopBar extends StatelessWidget implements PreferredSizeWidget {
style: NooText.title.copyWith(height: 1, color: colors.fg1), style: NooText.title.copyWith(height: 1, color: colors.fg1),
), ),
), ),
const SizedBox(width: NooSpace.md),
Expanded(child: androidTitleTrailing!), Expanded(child: androidTitleTrailing!),
] else ] else
Expanded( Expanded(
+22 -2
View File
@@ -11,8 +11,16 @@ class NooLayout {
/// (table rows, 5-column grids, 8-column photos, 24px gutter). /// (table rows, 5-column grids, 8-column photos, 24px gutter).
static const double desktopBreakpoint = 900; static const double desktopBreakpoint = 900;
static bool isDesktop(BuildContext context) => /// Shortest window side that counts as a tablet-class window (the usual
MediaQuery.sizeOf(context).width >= desktopBreakpoint; /// 600dp rule). A phone in landscape is wide but short, so it stays on the
/// mobile layout.
static const double tabletShortestSide = 600;
static bool isDesktop(BuildContext context) {
final size = MediaQuery.sizeOf(context);
return size.width >= desktopBreakpoint &&
size.shortestSide >= tabletShortestSide;
}
static NooNavStyle navStyle(BuildContext context) => static NooNavStyle navStyle(BuildContext context) =>
NooNavStyle.fromPlatform(Theme.of(context).platform); NooNavStyle.fromPlatform(Theme.of(context).platform);
@@ -24,4 +32,16 @@ class NooLayout {
/// Horizontal content gutter: 12 on mobile (cards), 24 on desktop. /// Horizontal content gutter: 12 on mobile (cards), 24 on desktop.
static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12; static double gutter(BuildContext context) => isDesktop(context) ? 24 : 12;
/// Grid column count for [minTile]-wide tiles, never fewer than [phone]
/// (so phones keep their usual count while wide non-sidebar windows, e.g. a
/// tablet in portrait, get more instead of a few huge tiles).
static int gridColumns(
BuildContext context, {
required int phone,
required double minTile,
}) {
final width = MediaQuery.sizeOf(context).width;
return (width / minTile).floor().clamp(phone, 12);
}
} }
+40 -6
View File
@@ -34,7 +34,10 @@ class NooShareSection extends StatelessWidget {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Text(title, style: NooText.sectionTitle.copyWith(color: colors.fg1)), child: Text(
title,
style: NooText.sectionTitle.copyWith(color: colors.fg1),
),
), ),
?trailing, ?trailing,
], ],
@@ -60,11 +63,16 @@ class NooPersonAccessRow extends StatelessWidget {
final String name; final String name;
final String? subtitle; final String? subtitle;
final bool owner; final bool owner;
/// Pill label for non-owners, e.g. "Can edit" / "Can view". /// Pill label for non-owners, e.g. "Can edit" / "Can view".
final String permission; final String permission;
final VoidCallback? onPermissionTap; final VoidCallback? onPermissionTap;
final bool permissionLoading;
final String ownerLabel; final String ownerLabel;
/// Replaces the owner label / permission pill (e.g. a plain search result).
final Widget? trailing;
const NooPersonAccessRow({ const NooPersonAccessRow({
super.key, super.key,
required this.avatar, required this.avatar,
@@ -73,7 +81,9 @@ class NooPersonAccessRow extends StatelessWidget {
this.owner = false, this.owner = false,
this.permission = 'Can edit', this.permission = 'Can edit',
this.onPermissionTap, this.onPermissionTap,
this.permissionLoading = false,
this.ownerLabel = 'Owner', this.ownerLabel = 'Owner',
this.trailing,
}); });
@override @override
@@ -113,10 +123,16 @@ class NooPersonAccessRow extends StatelessWidget {
), ),
), ),
const SizedBox(width: NooSpace.sm), const SizedBox(width: NooSpace.sm),
if (owner) if (trailing != null)
trailing!
else if (owner)
Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3)) Text(ownerLabel, style: NooText.label.copyWith(color: colors.fg3))
else else
NooPermissionPill(label: permission, onTap: onPermissionTap), NooPermissionPill(
label: permission,
onTap: onPermissionTap,
loading: permissionLoading,
),
], ],
), ),
); );
@@ -128,8 +144,14 @@ class NooPersonAccessRow extends StatelessWidget {
class NooPermissionPill extends StatelessWidget { class NooPermissionPill extends StatelessWidget {
final String label; final String label;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool loading;
const NooPermissionPill({super.key, required this.label, this.onTap}); const NooPermissionPill({
super.key,
required this.label,
this.onTap,
this.loading = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -147,9 +169,21 @@ class NooPermissionPill extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(label, style: NooText.label.copyWith(height: 1, color: colors.fg1)), Text(
label,
style: NooText.label.copyWith(height: 1, color: colors.fg1),
),
const SizedBox(width: NooSpace.xxs), const SizedBox(width: NooSpace.xxs),
Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2), loading
? SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.fg2,
),
)
: Icon(LucideIcons.chevronDown, size: 14, color: colors.fg2),
], ],
), ),
), ),
+18 -2
View File
@@ -64,6 +64,10 @@ class _NooSheetBody extends StatefulWidget {
class _NooSheetBodyState extends State<_NooSheetBody> { class _NooSheetBodyState extends State<_NooSheetBody> {
bool _needsPeek = false; bool _needsPeek = false;
// Keeps the sheet's content (and its State) alive when the body swaps
// between the plain and draggable layouts, instead of rebuilding it.
final _contentKey = GlobalKey();
void _handleOverflow(ScrollMetrics metrics) { void _handleOverflow(ScrollMetrics metrics) {
if (_needsPeek || metrics.maxScrollExtent <= 0) return; if (_needsPeek || metrics.maxScrollExtent <= 0) return;
// `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids // `ScrollMetricsNotification` is dispatched mid-layout; deferring avoids
@@ -84,6 +88,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
controller: scrollController, controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20), padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column( child: Column(
key: _contentKey,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
@@ -106,13 +111,23 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( // A modal bottom sheet doesn't avoid the keyboard itself, so a focused
// field would be covered. Inset by the keyboard and, while it's up, let
// the content use all the space above it.
final keyboard = MediaQuery.viewInsetsOf(context).bottom;
final keyboardUp = keyboard > 0;
return Padding(
padding: EdgeInsets.only(bottom: keyboard),
child: SafeArea(
bottom: !keyboardUp,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
if (!_needsPeek) { if (!_needsPeek) {
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxHeight: constraints.maxHeight * _kNooSheetPeekFraction, maxHeight: keyboardUp
? constraints.maxHeight
: constraints.maxHeight * _kNooSheetPeekFraction,
), ),
child: _buildScrollable(null), child: _buildScrollable(null),
); );
@@ -141,6 +156,7 @@ class _NooSheetBodyState extends State<_NooSheetBody> {
); );
}, },
), ),
),
); );
} }
} }
@@ -48,6 +48,8 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [ children: [
SettingsSection( SettingsSection(
title: 'Appearance', title: 'Appearance',
notice:
'Bottom bar style, menu style and search in bottom bar have no effect on wide tablets, which use a sidebar.',
children: [ children: [
_ThemeRow(settings: settings), _ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings), _BottomBarStyleRow(settings: settings),
@@ -67,6 +69,13 @@ class SettingsAppearanceSection extends StatelessWidget {
], ],
), ),
const SizedBox(height: NooSpace.xl), 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( SettingsSection(
title: 'Advanced appearance', title: 'Advanced appearance',
children: [ children: [
@@ -291,6 +300,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 /// The current accent-color choice, shown as a small filled circle in the
/// "Accent color" row - a wallpaper icon when following the OS's dynamic /// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch. /// color instead of a fixed swatch.
+10 -7
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/lists/noo_grouped_list.dart'; import '../noo/lists/noo_grouped_list.dart';
import '../noo/lists/noo_info_note.dart';
import '../noo/noo_layout.dart'; import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart'; import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart'; import '../noo/overlays/noo_sheet.dart';
@@ -17,10 +18,14 @@ class SettingsSection extends StatelessWidget {
final String? subtitle; final String? subtitle;
final List<Widget> children; final List<Widget> children;
/// Shown as a prominent [NooInfoNote] above the rows.
final String? notice;
const SettingsSection({ const SettingsSection({
super.key, super.key,
required this.title, required this.title,
this.subtitle, this.subtitle,
this.notice,
required this.children, required this.children,
}); });
@@ -30,14 +35,8 @@ class SettingsSection extends StatelessWidget {
if (!NooLayout.isDesktop(context)) { if (!NooLayout.isDesktop(context)) {
return NooGroupedList( return NooGroupedList(
label: title, label: title,
notice: notice != null ? NooInfoNote(message: notice!) : null,
footer: subtitle != null ? Text(subtitle!) : null, footer: subtitle != null ? Text(subtitle!) : null,
// 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, children: children,
); );
} }
@@ -57,6 +56,10 @@ class SettingsSection extends StatelessWidget {
Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)), Text(subtitle!, style: NooText.meta.copyWith(color: colors.fg3)),
], ],
const SizedBox(height: NooSpace.md), const SizedBox(height: NooSpace.md),
if (notice != null) ...[
NooInfoNote(message: notice!),
const SizedBox(height: NooSpace.md),
],
FlatRowGroup(children: children), FlatRowGroup(children: children),
], ],
), ),
+6 -1
View File
@@ -37,7 +37,11 @@ class SettingsSwipeSection extends StatelessWidget {
NooSettingsRow( NooSettingsRow(
label: Text(_swipeActionLabel(action)), label: Text(_swipeActionLabel(action)),
trailing: action == current trailing: action == current
? Icon(LucideIcons.check, size: 18, color: context.nooColors.accentText) ? Icon(
LucideIcons.check,
size: 18,
color: context.nooColors.accentText,
)
: null, : null,
onTap: () { onTap: () {
onChanged(action); onChanged(action);
@@ -55,6 +59,7 @@ class SettingsSwipeSection extends StatelessWidget {
return SettingsSection( return SettingsSection(
title: 'Swipe on a file', title: 'Swipe on a file',
subtitle: 'Choose what swiping a file left or right does in list view', subtitle: 'Choose what swiping a file left or right does in list view',
notice: 'Swipe actions have no effect on wide tablets.',
children: [ children: [
NooSettingsRow( NooSettingsRow(
icon: LucideIcons.chevronsRight, icon: LucideIcons.chevronsRight,
+174 -24
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -18,6 +19,7 @@ import 'noo/core/noo_avatar.dart';
import 'noo/core/noo_button.dart'; import 'noo/core/noo_button.dart';
import 'noo/core/noo_chip.dart'; import 'noo/core/noo_chip.dart';
import 'noo/core/noo_toggle.dart'; import 'noo/core/noo_toggle.dart';
import 'noo/lists/noo_grouped_list.dart';
import 'noo/lists/noo_settings_row.dart'; import 'noo/lists/noo_settings_row.dart';
import 'noo/media/noo_photo_tile.dart'; import 'noo/media/noo_photo_tile.dart';
import 'noo/noo_layout.dart'; import 'noo/noo_layout.dart';
@@ -71,11 +73,13 @@ class ShareSheet extends StatefulWidget {
return showNooSheet( return showNooSheet(
context, context,
children: [ children: [
NooOverlayHeader( Builder(
builder: (sheetContext) => NooOverlayHeader(
leading: detailsFileTile(item), leading: detailsFileTile(item),
title: item.name, title: item.name,
subtitle: detailsMetaLine(item), subtitle: detailsMetaLine(item),
onClose: () => Navigator.pop(context), onClose: () => Navigator.of(sheetContext).pop(),
),
), ),
ShareSheet(item: item), ShareSheet(item: item),
], ],
@@ -95,13 +99,42 @@ class _ShareSheetState extends State<ShareSheet> {
bool _isSearching = false; bool _isSearching = false;
bool _isAddingPerson = false; bool _isAddingPerson = false;
bool _isTogglingLink = false; bool _isTogglingLink = false;
String? _pendingShareeKey;
final Set<String> _updatingShareIds = {};
bool _isSharingFile = false; bool _isSharingFile = false;
bool _showInherited = false; bool _showInherited = false;
final _peopleController = TextEditingController(); final _peopleController = TextEditingController();
final _linkController = TextEditingController(); final _linkController = TextEditingController();
final _peopleFocus = FocusNode();
final _peopleKey = GlobalKey();
/// Once the user types, scrolls the sheet so the people field and its
/// results (under their title) sit near the top, leaving a little room under the sheet's
/// rounded top edge.
void _revealPeopleSection() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _peopleKey.currentContext;
final box = ctx?.findRenderObject();
if (ctx == null || box is! RenderBox) return;
final position = Scrollable.maybeOf(ctx)?.position;
final viewport = RenderAbstractViewport.maybeOf(box);
if (position == null || viewport == null) return;
final target = (viewport.getOffsetToReveal(box, 0).offset - 8).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
position.animateTo(
target,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
});
}
@override @override
void dispose() { void dispose() {
_peopleFocus.dispose();
_peopleController.dispose(); _peopleController.dispose();
_linkController.dispose(); _linkController.dispose();
super.dispose(); super.dispose();
@@ -128,6 +161,7 @@ class _ShareSheetState extends State<ShareSheet> {
return; return;
} }
setState(() => _isSearching = true); setState(() => _isSearching = true);
_revealPeopleSection();
final ops = context.read<ItemOperations>(); final ops = context.read<ItemOperations>();
final results = await ops.searchSharees(trimmed); final results = await ops.searchSharees(trimmed);
if (!mounted) return; if (!mounted) return;
@@ -135,6 +169,7 @@ class _ShareSheetState extends State<ShareSheet> {
_searchResults = results; _searchResults = results;
_isSearching = false; _isSearching = false;
}); });
_revealPeopleSection();
} }
/// The single "Name, email or group" field: reuses whichever match is /// The single "Name, email or group" field: reuses whichever match is
@@ -151,15 +186,20 @@ class _ShareSheetState extends State<ShareSheet> {
} }
} }
String _shareeKey(NextcloudSharee s) => '${s.shareTypeValue}:${s.shareWith}';
Future<void> _addSharee(NextcloudSharee sharee) async { Future<void> _addSharee(NextcloudSharee sharee) async {
if (_pendingShareeKey != null) return;
final ops = context.read<ItemOperations>(); final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
setState(() => _pendingShareeKey = _shareeKey(sharee));
final share = await ops.createShare( final share = await ops.createShare(
path: widget.item.path, path: widget.item.path,
shareType: sharee.shareTypeValue, shareType: sharee.shareTypeValue,
shareWith: sharee.shareWith, shareWith: sharee.shareWith,
); );
if (!mounted) return; if (!mounted) return;
setState(() => _pendingShareeKey = null);
if (share == null) { if (share == null) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar( const SnackBar(
@@ -321,36 +361,93 @@ class _ShareSheetState extends State<ShareSheet> {
} }
} }
/// A small popup with the one action the compact [NooPersonAccessRow] /// Permission menu for a person row: Can view / Can edit, then removing
/// permission pill has no room for - removing that person's access. There's /// access. Keeps the reshare bit (16) as it was.
/// no permission-update endpoint on [ItemOperations] yet, so the pill
/// itself is read-only (see the rebuild report).
void _openPersonMenu(NextcloudShare share) { void _openPersonMenu(NextcloudShare share) {
final label = share.sharedWithDisplayName ?? 'Shared'; final label = share.sharedWithDisplayName ?? 'Shared';
final canEdit = (share.permissions & 2) != 0;
void remove() { void remove() {
Navigator.pop(context); Navigator.pop(context);
_removeShare(share); _removeShare(share);
} }
final row = NooSettingsRow( void choose(bool edit) {
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, icon: LucideIcons.userMinus,
label: const Text('Remove access'), label: const Text('Remove access'),
danger: true, danger: true,
onTap: remove, onTap: remove,
); ),
];
if (NooLayout.isDesktop(context)) { if (NooLayout.isDesktop(context)) {
showNooDialog(context, title: label, children: [row]); showNooDialog(
context,
title: label,
children: [Column(children: rows)],
);
} else { } else {
showNooSheet( showNooSheet(
context, context,
children: [ children: [
NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)), NooOverlayHeader(title: label, onClose: () => Navigator.pop(context)),
row, NooGroupedList(children: rows),
], ],
); );
} }
} }
Future<void> _setPermission(NextcloudShare share, bool edit) async {
if (((share.permissions & 2) != 0) == edit) return;
final ops = context.read<ItemOperations>();
final messenger = ScaffoldMessenger.of(context);
final reshare = share.permissions & 16;
final base = edit ? (share.isFolder ? 15 : 3) : 1;
final permissions = base | reshare;
setState(() => _updatingShareIds.add(share.id));
final ok = await ops.updateSharePermissions(share, permissions);
if (!mounted) return;
setState(() => _updatingShareIds.remove(share.id));
if (ok) {
setState(() {
_shares = [
for (final s in _shares)
s.id == share.id ? s.withPermissions(permissions) : s,
];
});
} else {
messenger.showSnackBar(
const SnackBar(
content: Text('Could not change permission'),
behavior: SnackBarBehavior.floating,
),
);
}
}
Widget _peopleSection( Widget _peopleSection(
NooColors colors, NooColors colors,
SessionController session, SessionController session,
@@ -364,6 +461,7 @@ class _ShareSheetState extends State<ShareSheet> {
absorbing: _isAddingPerson, absorbing: _isAddingPerson,
child: NooTextField( child: NooTextField(
controller: _peopleController, controller: _peopleController,
focusNode: _peopleFocus,
leadingIcon: LucideIcons.search, leadingIcon: LucideIcons.search,
placeholder: 'Name, email or group', placeholder: 'Name, email or group',
onChanged: _search, onChanged: _search,
@@ -381,17 +479,40 @@ class _ShareSheetState extends State<ShareSheet> {
), ),
), ),
for (final sharee in _searchResults) for (final sharee in _searchResults)
NooSettingsRow( InkWell(
borderRadius: BorderRadius.circular(NooRadii.input),
onTap: _pendingShareeKey == null ? () => _addSharee(sharee) : null,
child: NooPersonAccessRow(
avatar: NooAvatar(
initials: _initial(sharee.label),
color: NooPhotoTile.paletteColor(sharee.label.hashCode),
icon: sharee.type == ShareeType.group icon: sharee.type == ShareeType.group
? LucideIcons.users ? LucideIcons.users
: LucideIcons.user, : null,
label: Text(sharee.label),
subtitle: sharee.subtitle != null ? Text(sharee.subtitle!) : null,
onTap: () => _addSharee(sharee),
), ),
name: sharee.label,
subtitle: sharee.subtitle,
trailing: _pendingShareeKey == _shareeKey(sharee)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const SizedBox.shrink(),
),
),
if (_searchResults.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: NooSpace.xs),
child: Divider(height: 1, thickness: 1, color: colors.line),
)
else
const SizedBox(height: NooSpace.xs), const SizedBox(height: NooSpace.xs),
NooPersonAccessRow( NooPersonAccessRow(
avatar: NooAvatar(initials: _initial(session.username), current: true), avatar: NooAvatar(
initials: _initial(session.username),
current: true,
),
name: session.username, name: session.username,
owner: true, owner: true,
), ),
@@ -402,14 +523,21 @@ class _ShareSheetState extends State<ShareSheet> {
color: NooPhotoTile.paletteColor( color: NooPhotoTile.paletteColor(
(share.sharedWithDisplayName ?? share.id).hashCode, (share.sharedWithDisplayName ?? share.id).hashCode,
), ),
icon: share.shareType == ShareType.group ? LucideIcons.users : null, icon: share.shareType == ShareType.group
? LucideIcons.users
: null,
), ),
name: share.sharedWithDisplayName ?? 'Shared', name: share.sharedWithDisplayName ?? 'Shared',
subtitle: share.shareType == ShareType.group subtitle: share.shareType == ShareType.group
? 'Group' ? 'Group'
: (share.shareType == ShareType.email ? 'Invited by email' : null), : (share.shareType == ShareType.email
? 'Invited by email'
: null),
permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view', permission: (share.permissions & 2) != 0 ? 'Can edit' : 'Can view',
onPermissionTap: () => _openPersonMenu(share), permissionLoading: _updatingShareIds.contains(share.id),
onPermissionTap: _updatingShareIds.contains(share.id)
? null
: () => _openPersonMenu(share),
), ),
if (_inherited.isNotEmpty) ...[ if (_inherited.isNotEmpty) ...[
const SizedBox(height: NooSpace.xs), const SizedBox(height: NooSpace.xs),
@@ -429,7 +557,9 @@ class _ShareSheetState extends State<ShareSheet> {
), ),
), ),
Icon( Icon(
_showInherited ? LucideIcons.chevronUp : LucideIcons.chevronDown, _showInherited
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 16, size: 16,
color: colors.fg3, color: colors.fg3,
), ),
@@ -464,7 +594,10 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Internal link', style: NooText.bodyL.copyWith(color: colors.fg1)), Text(
'Internal link',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
Text( Text(
'For people who already have access', 'For people who already have access',
style: NooText.meta.copyWith(color: colors.fg3), style: NooText.meta.copyWith(color: colors.fg3),
@@ -559,26 +692,43 @@ class _ShareSheetState extends State<ShareSheet> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
NooShareSection( NooShareSection(
key: _peopleKey,
title: 'Share with people', title: 'Share with people',
child: _peopleSection(colors, session, peopleShares, internalLink), child: _peopleSection(colors, session, peopleShares, internalLink),
), ),
const SizedBox(height: 22), const SizedBox(height: 22),
NooShareSection( NooShareSection(
title: 'Share link', title: 'Share link',
trailing: NooToggle( trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isTogglingLink) ...[
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: NooSpace.sm),
],
NooToggle(
checked: hasLink, checked: hasLink,
onChanged: _isTogglingLink ? null : _toggleLink, onChanged: _isTogglingLink ? null : _toggleLink,
), ),
],
),
caption: hasLink caption: hasLink
? null ? null
: 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.', : 'Anyone with the link can access this ${widget.item.isFolder ? 'folder' : 'file'}.',
child: hasLink ? _linkSection(publicLinkShares.first) : const SizedBox.shrink(), child: hasLink
? _linkSection(publicLinkShares.first)
: const SizedBox.shrink(),
), ),
if (!widget.item.isFolder) ...[ if (!widget.item.isFolder) ...[
const SizedBox(height: 22), const SizedBox(height: 22),
NooShareSection( NooShareSection(
title: 'Send file directly', title: 'Send file directly',
caption: "Link settings above don't apply - this sends the file's bytes directly.", caption:
"Link settings above don't apply - this sends the file's bytes directly.",
child: NooButton( child: NooButton(
variant: NooButtonVariant.outline, variant: NooButtonVariant.outline,
size: NooButtonSize.card, size: NooButtonSize.card,
+12 -3
View File
@@ -22,9 +22,14 @@ class MediaActionBar extends StatelessWidget {
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback onOpenExternally; final VoidCallback onOpenExternally;
final VoidCallback onDownload; final VoidCallback onDownload;
final VoidCallback onDetails; final VoidCallback? onDetails;
final Widget? handle;
final Widget? below;
const MediaActionBar({ const MediaActionBar({
this.onDetails,
this.handle,
this.below,
super.key, super.key,
required this.isFavorite, required this.isFavorite,
required this.isBusy, required this.isBusy,
@@ -35,7 +40,6 @@ class MediaActionBar extends StatelessWidget {
required this.onDelete, required this.onDelete,
required this.onOpenExternally, required this.onOpenExternally,
required this.onDownload, required this.onDownload,
required this.onDetails,
}); });
@override @override
@@ -48,11 +52,15 @@ class MediaActionBar extends StatelessWidget {
// short and leaving that strip unstyled - only the actual row content // short and leaving that strip unstyled - only the actual row content
// needs padding up and away from the gesture area. // needs padding up and away from the gesture area.
return FrostedGlassContainer( return FrostedGlassContainer(
radius: const BorderRadius.vertical(
top: Radius.circular(NooRadii.sheetTop),
),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
?handle,
if (transportControls != null) ...[ if (transportControls != null) ...[
transportControls!, transportControls!,
Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)), Divider(height: 1, color: colors.fg1.withValues(alpha: 0.14)),
@@ -94,9 +102,9 @@ class MediaActionBar extends StatelessWidget {
ViewerIconButton( ViewerIconButton(
icon: LucideIcons.trash2, icon: LucideIcons.trash2,
tooltip: 'Delete', tooltip: 'Delete',
color: colors.danger,
onTap: isBusy ? null : onDelete, onTap: isBusy ? null : onDelete,
), ),
if (onDetails != null)
ViewerIconButton( ViewerIconButton(
icon: LucideIcons.info, icon: LucideIcons.info,
tooltip: 'Details', tooltip: 'Details',
@@ -105,6 +113,7 @@ class MediaActionBar extends StatelessWidget {
], ],
), ),
), ),
?below,
], ],
), ),
), ),
+133
View File
@@ -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),
);
}
}
+63 -3
View File
@@ -17,8 +17,12 @@ class MediaPdfPreview extends StatefulWidget {
/// `FileViewerScreen.localPathResolver`'s doc comment. /// `FileViewerScreen.localPathResolver`'s doc comment.
final Future<String?> Function(NextcloudItem item)? localPathResolver; final Future<String?> Function(NextcloudItem item)? localPathResolver;
/// Space reserved above the page for an overlaid top bar (0 when hidden).
final double topInset;
const MediaPdfPreview({ const MediaPdfPreview({
super.key, super.key,
this.topInset = 0,
required this.item, required this.item,
required this.session, required this.session,
this.localPathResolver, this.localPathResolver,
@@ -29,8 +33,14 @@ class MediaPdfPreview extends StatefulWidget {
} }
class _MediaPdfPreviewState extends State<MediaPdfPreview> { class _MediaPdfPreviewState extends State<MediaPdfPreview> {
// Only the first few pages are measured; longer documents are assumed
// taller than any screen.
static const _sizedPages = 3;
PdfControllerPinch? _controller; PdfControllerPinch? _controller;
String? _error; String? _error;
List<Size> _pageSizes = const [];
int _pageCount = 0;
@override @override
void initState() { void initState() {
@@ -55,9 +65,21 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
await widget.session.service!.fetchBytes(widget.item.path), await widget.session.service!.fetchBytes(widget.item.path),
); );
} }
if (!mounted) return; final document = await PdfDocument.openData(bytes);
final sizes = <Size>[];
for (var i = 1; i <= document.pagesCount && i <= _sizedPages; i++) {
// autoCloseAndroid closes the previous page itself; closing here too throws.
final page = await document.getPage(i, autoCloseAndroid: true);
sizes.add(Size(page.width, page.height));
}
if (!mounted) {
await document.close();
return;
}
setState(() { setState(() {
_controller = PdfControllerPinch(document: PdfDocument.openData(bytes)); _pageSizes = sizes;
_pageCount = document.pagesCount;
_controller = PdfControllerPinch(document: Future.value(document));
}); });
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = e.toString()); if (mounted) setState(() => _error = e.toString());
@@ -91,6 +113,44 @@ class _MediaPdfPreviewState extends State<MediaPdfPreview> {
// without ever pinching to zoom. There's no way to override that // without ever pinching to zoom. There's no way to override that
// margin from here - it isn't an exposed parameter - so this avoids // margin from here - it isn't an exposed parameter - so this avoids
// the branch that sets it instead of fighting it. // the branch that sets it instead of fighting it.
return PdfViewPinch(controller: _controller!); //
// pdfx's InteractiveViewer also refuses to zoom out past
// `viewport.height / document.height`. When the whole document is
// shorter than the screen (e.g. a one-page PDF) that ratio is above 1, so
// after pinching in, fit-width (scale 1) becomes unreachable. PdfViewPinch
// wraps its content in a SafeArea, which counts MediaQuery padding toward
// that document height, so padding the bottom up to the viewport height
// keeps the ratio at or below 1 without shrinking the gesture area.
return AnimatedPadding(
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
padding: EdgeInsets.only(top: widget.topInset),
child: LayoutBuilder(
builder: (context, constraints) {
final media = MediaQuery.of(context);
var padding = media.padding.copyWith(top: 0);
if (_pageSizes.isNotEmpty && _pageSizes.length == _pageCount) {
const pagePadding = 10.0; // PdfViewPinch's default `padding`
final maxWidth = _pageSizes.fold<double>(
0,
(m, s) => s.width > m ? s.width : m,
);
final ratio = (constraints.maxWidth - pagePadding * 2) / maxWidth;
final docHeight = _pageSizes.fold<double>(
pagePadding,
(h, s) => h + s.height * ratio + pagePadding,
);
final needed = constraints.maxHeight - docHeight;
if (needed > padding.bottom) {
padding = padding.copyWith(bottom: needed);
}
}
return MediaQuery(
data: media.copyWith(padding: padding),
child: PdfViewPinch(controller: _controller!),
);
},
),
);
} }
} }
+154 -10
View File
@@ -1,12 +1,25 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../models/nextcloud_item.dart'; import '../../models/nextcloud_item.dart';
import '../../providers/session_controller.dart'; import '../../providers/session_controller.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../noo/core/noo_button.dart';
/// Plain monospace text-file preview for [FileViewerScreen]'s static (non- /// Space the viewer's floating top bar and bottom action bar cover, so the
/// swipeable) preview path (`_textPreviewExtensions`). /// text sits clear of them (and the status bar) instead of scrolling
/// underneath.
const double _kTopBarClearance = 72;
const double _kActionBarClearance = 112;
/// Text/markdown file preview and editor for [FileViewerScreen]'s static
/// (non-swipeable) path (`_textPreviewExtensions`). Content is always
/// editable when online; a Save button appears once it has changed. Markdown
/// files open rendered, with an Edit/Preview toggle. Opened
/// from the Offline tab ([localPathResolver] set) it is read-only, since the
/// local copy has no server to write back to.
class MediaTextPreview extends StatefulWidget { class MediaTextPreview extends StatefulWidget {
final NextcloudItem item; final NextcloudItem item;
final SessionController session; final SessionController session;
@@ -28,15 +41,35 @@ class MediaTextPreview extends StatefulWidget {
} }
class _MediaTextPreviewState extends State<MediaTextPreview> { class _MediaTextPreviewState extends State<MediaTextPreview> {
String? _content; final _controller = TextEditingController();
String? _saved;
String? _error; String? _error;
bool _isSaving = false;
bool _editing = false;
bool get _isMarkdown {
final name = widget.item.name.toLowerCase();
return name.endsWith('.md') || name.endsWith('.markdown');
}
bool get _showRendered => _isMarkdown && !_editing;
bool get _readOnly => widget.localPathResolver != null;
bool get _dirty => _saved != null && _controller.text != _saved;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller.addListener(() => setState(() {}));
_load(); _load();
} }
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _load() async { Future<void> _load() async {
try { try {
final List<int> bytes; final List<int> bytes;
@@ -53,13 +86,39 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
bytes = await widget.session.service!.fetchBytes(widget.item.path); bytes = await widget.session.service!.fetchBytes(widget.item.path);
} }
if (mounted) { if (mounted) {
setState(() => _content = utf8.decode(bytes, allowMalformed: true)); final text = utf8.decode(bytes, allowMalformed: true);
_controller.text = text;
setState(() => _saved = text);
} }
} catch (e) { } catch (e) {
if (mounted) setState(() => _error = e.toString()); if (mounted) setState(() => _error = e.toString());
} }
} }
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context);
final text = _controller.text;
setState(() => _isSaving = true);
var ok = false;
try {
ok = await widget.session.service!.putBytes(
widget.item.path,
utf8.encode(text),
);
} catch (_) {}
if (!mounted) return;
setState(() {
_isSaving = false;
if (ok) _saved = text;
});
messenger.showSnackBar(
SnackBar(
content: Text(ok ? 'Saved ${widget.item.name}' : 'Could not save file'),
behavior: SnackBarBehavior.floating,
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = context.nooColors; final colors = context.nooColors;
@@ -71,18 +130,103 @@ class _MediaTextPreviewState extends State<MediaTextPreview> {
), ),
); );
} }
if (_content == null) { if (_saved == null) {
return Center(child: CircularProgressIndicator(color: colors.accent)); return Center(child: CircularProgressIndicator(color: colors.accent));
} }
final safe = MediaQuery.paddingOf(context);
final style = NooText.mono.copyWith(color: colors.fg1, height: 1.5);
final padding = EdgeInsets.fromLTRB(
20,
safe.top + _kTopBarClearance,
20,
safe.bottom + _kActionBarClearance,
);
return Container( return Container(
color: colors.surface, color: colors.surface,
padding: const EdgeInsets.all(20), child: Stack(
child: SingleChildScrollView( children: [
child: SelectableText( Positioned.fill(
_content!, child: _showRendered
style: NooText.mono.copyWith(color: colors.fg1, height: 1.5), ? Markdown(
data: _controller.text,
padding: padding,
selectable: true,
styleSheet: _markdownStyle(context, colors),
onTapLink: (_, href, _) {},
)
: SingleChildScrollView(
padding: padding,
child: TextField(
controller: _controller,
readOnly: _readOnly,
maxLines: null,
keyboardType: TextInputType.multiline,
style: style,
cursorColor: colors.accentText,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
), ),
), ),
),
),
if (_isMarkdown && !_readOnly)
Positioned(
right: 20,
top: safe.top + _kTopBarClearance - 8,
child: NooButton(
variant: NooButtonVariant.tonal,
size: NooButtonSize.compact,
icon: _editing ? LucideIcons.eye : LucideIcons.pencil,
onTap: () => setState(() => _editing = !_editing),
child: Text(_editing ? 'Preview' : 'Edit'),
),
),
if (_dirty)
Positioned(
right: 20,
bottom: safe.bottom + _kActionBarClearance,
child: NooButton(
size: NooButtonSize.card,
icon: LucideIcons.save,
disabled: _isSaving,
onTap: _isSaving ? null : _save,
child: Text(_isSaving ? 'Saving…' : 'Save'),
),
),
],
),
); );
} }
} }
MarkdownStyleSheet _markdownStyle(BuildContext context, NooColors colors) {
final body = NooText.body.copyWith(color: colors.fg1, height: 1.55);
TextStyle heading(double size) =>
NooText.title.copyWith(color: colors.fg1, fontSize: size, height: 1.25);
return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
p: body,
h1: heading(26),
h2: heading(22),
h3: heading(19),
h4: heading(17),
listBullet: body,
a: body.copyWith(color: colors.accentText),
blockquote: body.copyWith(color: colors.fg2),
blockquoteDecoration: BoxDecoration(
border: Border(left: BorderSide(color: colors.line, width: 3)),
),
code: NooText.mono.copyWith(
color: colors.fg1,
backgroundColor: colors.surface2,
),
codeblockDecoration: BoxDecoration(
color: colors.surface2,
borderRadius: BorderRadius.circular(NooRadii.input),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(top: BorderSide(color: colors.line)),
),
);
}
+16 -39
View File
@@ -254,11 +254,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -267,6 +262,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown_plus
sha256: fce641d6c2106cc495de1cd603f97a4f9d615a97a64011928ded380dbdade935
url: "https://pub.dev"
source: hosted
version: "1.0.12"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -333,11 +336,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -378,11 +376,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -503,6 +496,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.20" version: "3.1.20"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
marquee: marquee:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -791,14 +792,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
process:
dependency: transitive
description:
name: process
sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891"
url: "https://pub.dev"
source: hosted
version: "5.0.6"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -932,14 +925,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@@ -1084,14 +1069,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
webview_flutter: webview_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
+4 -7
View File
@@ -1,5 +1,5 @@
name: noo name: noo
description: "A new Flutter project." description: "Noo - a fast, native Nextcloud client built with Flutter."
# The following line prevents the package from being accidentally published to # The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages. # pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev publish_to: 'none' # Remove this line if you wish to publish to pub.dev
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.0.1+2 version: 1.0.2+5
environment: environment:
sdk: ^3.12.2 sdk: ^3.12.2
@@ -58,16 +58,12 @@ dependencies:
webview_flutter: ^4.14.1 webview_flutter: ^4.14.1
permission_handler: ^11.3.1 permission_handler: ^11.3.1
connectivity_plus: ^6.1.5 connectivity_plus: ^6.1.5
flutter_markdown_plus: ^1.0.12
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
# Drives tool/screenshots.sh (Play Store screenshots against a fake-data
# demo server) - dev only, never part of the release build.
integration_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to # The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is # encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your # activated in the `analysis_options.yaml` file located at the root of your
@@ -89,6 +85,7 @@ flutter:
assets: assets:
- assets/icon/app_icon.png - assets/icon/app_icon.png
- assets/icon/app_icon_monochrome.png - assets/icon/app_icon_monochrome.png
- assets/google_fonts/
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
+53
View File
@@ -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
-33
View File
@@ -1,33 +0,0 @@
// Host-side driver for integration_test/store_screenshots_test.dart (run via
// tool/screenshots.sh). Each time the test asks for a screenshot this saves
// a PNG named after it into $SCREENSHOT_DIR.
//
// These bytes are the Flutter surface only (no OS status bar) - an earlier
// version of this file tried to re-capture the screen with `adb exec-out
// screencap` for a "real" screenshot including it, but on Android,
// `onScreenshot` only fires once the whole test has finished (see
// `IntegrationTestWidgetsFlutterBinding`/`integrationDriver`'s source: the
// native platform channel hands screenshot bytes straight to `reportData`
// with no round trip to the driver, and the driver's first `requestData`
// call blocks until every `testWidgets` body in the file has returned) - so
// every `adb screencap` call was capturing the same final frame, regardless
// of which named screenshot it was meant to be. Saving these bytes directly
// is what actually gives each name its own distinct image.
import 'dart:io';
import 'package:integration_test/integration_test_driver_extended.dart';
Future<void> main() async {
final outDir = Directory(
Platform.environment['SCREENSHOT_DIR'] ?? 'store_listing/screenshots',
)..createSync(recursive: true);
await integrationDriver(
onScreenshot:
(String name, List<int> bytes, [Map<String, Object?>? args]) async {
File('${outDir.path}/$name.png').writeAsBytesSync(bytes);
stdout.writeln('saved $name.png');
return true;
},
);
}
-48
View File
@@ -1,48 +0,0 @@
# Demo server for Play Store screenshots
`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway
Nextcloud that only contains fake data**, so nothing real can appear in them.
This folder is that server:
- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing
persisted; `docker compose down -v` wipes it).
- `seed.py` - creates the users and fills them with invented content: landscape
"photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites,
shares (to a second user and a public link), trash, a filler file for the
storage quota, and edits by a second user so the Activity feed has more than
one author. It ends by printing `APP_PASSWORD=...` - an app password for the
demo user, so the app can start logged in without the browser login step.
## Running it
Most of the time just run the whole pipeline from the repo root:
```bash
bash tool/screenshots.sh
```
(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter**
and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed
into `tool/demo_server/.venv` automatically if your Python lacks them. The
script installs the emulator and a system image through `sdkmanager` on first
use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`,
`ANDROID_SERIAL`, ...) are documented at the top of the script.
To poke at the server yourself:
```bash
docker compose -f tool/demo_server/docker-compose.yml up -d
python3 tool/demo_server/seed.py # prints APP_PASSWORD=...
# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password)
docker compose -f tool/demo_server/docker-compose.yml down -v
```
`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated
files to `DIR` without any server - handy for previewing the fake content.
## Before you upload
Look at every image. The known places where real data could still show up:
the server address and username (sync header panel, Settings > Accounts), the
avatar initial, notification icons in the status bar, and anything left over
from a previously used emulator profile (the script always wipes its own).
-16
View File
@@ -1,16 +0,0 @@
# A throwaway Nextcloud that only ever holds fake data - the backend the Play
# Store screenshots are taken against (see tool/screenshots.sh). Nothing is
# persisted: `docker compose down -v` wipes it completely.
services:
nextcloud:
image: ${NEXTCLOUD_IMAGE:-nextcloud:apache}
ports:
- "${DEMO_PORT:-80}:80"
environment:
# SQLite keeps this to a single container.
SQLITE_DATABASE: nextcloud
NEXTCLOUD_ADMIN_USER: admin
NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password
# `localhost` is how the Android emulator reaches this server (via
# `adb reverse`); DEMO_HOST is the optional friendlier hostname.
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}"
-2
View File
@@ -1,2 +0,0 @@
requests>=2.31
Pillow>=10.0
-348
View File
@@ -1,348 +0,0 @@
#!/usr/bin/env python3
"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots.
Everything here is invented or procedurally generated (landscape "photos"
drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real -
and nothing with a licence or a face in it - can end up in a screenshot.
python3 seed.py seed http://localhost (DEMO_BASE_URL)
python3 seed.py --generate-only D write the fake files under D, no server
On success the LAST line printed to stdout is `APP_PASSWORD=<token>` - an app
password for the demo user, which tool/screenshots.sh hands to the app so it
can start logged in without going through the browser login flow. All
progress output goes to stderr.
"""
import argparse
import io
import math
import os
import random
import shutil
import subprocess
import sys
import tempfile
import time
from urllib.parse import quote
from PIL import Image, ImageDraw, ImageFilter
BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/")
ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password"))
FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300"))
# The account the screenshots are taken as, and a second user to share with.
USERS = {
"Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"),
"Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"),
}
def log(message):
print(message, file=sys.stderr, flush=True)
# --------------------------------------------------------------- content ---
# (sky top, sky bottom, sun, far hills, mid hills, near hills)
PALETTES = [
((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)),
((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)),
((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)),
((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)),
((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)),
((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)),
((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)),
((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)),
]
def _mix(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def landscape(seed, size=(1600, 1067)):
"""A soft, layered-hills landscape - reads as a photo at thumbnail size."""
rnd = random.Random(seed)
top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)]
w, h = size
img = Image.new("RGB", size)
draw = ImageDraw.Draw(img)
for y in range(h):
draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75))))
sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7)
draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun)
for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)):
phase = [rnd.uniform(0, math.tau) for _ in range(3)]
freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)]
weight = (1.0, 0.5, 0.2)
pts = [(0, h)]
for x in range(0, w + 8, 8):
v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase))
pts.append((x, int(h * base + h * amp * v / 1.7)))
pts.append((w, h))
draw.polygon(pts, fill=color)
img = img.filter(ImageFilter.GaussianBlur(0.8))
out = io.BytesIO()
img.save(out, "JPEG", quality=86)
return out.getvalue()
def pdf(title, lines):
"""A tiny but valid one-page PDF."""
def esc(s):
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title)
for i, line in enumerate(lines):
body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line))
stream = body.encode("latin-1", "replace")
objs = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
b"/Resources << /Font << /F1 5 0 R >> >> >>",
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
]
out = bytearray(b"%PDF-1.4\n")
offsets = []
for i, obj in enumerate(objs, start=1):
offsets.append(len(out))
out += b"%d 0 obj\n" % i + obj + b"\nendobj\n"
xref = len(out)
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for off in offsets:
out += b"%010d 00000 n \n" % off
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
return bytes(out)
def text(body):
return body.strip().encode("utf-8") + b"\n"
def video():
"""A short test-pattern clip if ffmpeg is around, else None (skipped)."""
if not shutil.which("ffmpeg"):
return None
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "clip.mp4")
result = subprocess.run(
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24",
"-pix_fmt", "yuv420p", path],
capture_output=True,
)
if result.returncode != 0:
return None
with open(path, "rb") as f:
return f.read()
def build_content():
"""(path, bytes) for everything the demo user owns, plus what to favorite/delete."""
files = []
def add(path, data):
if data is not None:
files.append((path, data))
add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go."))
add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter"))
add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"]))
add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"]))
add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite"))
add("/Documents/Budget 2026.xlsx", os.urandom(24_000))
add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"]))
add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"]))
trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"]
for i, name in enumerate(trips):
add("/Trips/%s.jpg" % name, landscape(i + 1))
add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"]))
for i in range(8):
add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i))
add("/Photos/Summer 2026/Clip - hike.mp4", video())
add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper."))
add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt."))
add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans"))
add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"]))
add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}'))
add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets."))
favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"]
# Created, then deleted - they end up in the Trash tab.
trashed = [
("/Old draft.txt", text("Superseded draft.")),
("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))),
("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])),
]
return files, favorites, trashed
# ---------------------------------------------------------------- server ---
class ZeroStream:
"""`size` zero bytes, streamed - a big filler file without holding it in memory."""
def __init__(self, size):
self.size, self.pos = size, 0
def __len__(self):
return self.size
def read(self, n=-1):
n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos)
self.pos += n
return b"\0" * n
class Server:
def __init__(self):
import requests # imported lazily so --generate-only needs no network libs
self.requests = requests
def _call(self, method, url, auth, **kw):
headers = kw.pop("headers", {})
headers.setdefault("OCS-APIRequest", "true")
return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw)
def wait_ready(self):
log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...")
deadline = time.time() + 600
while time.time() < deadline:
try:
r = self.requests.get(BASE + "/status.php", timeout=5)
if r.ok and r.json().get("installed") and not r.json().get("maintenance"):
return
except Exception:
pass
time.sleep(3)
sys.exit("Nextcloud did not become ready in time")
def ocs(self, method, path, auth, **kw):
r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw)
try:
return r.json()["ocs"]
except Exception:
return {"meta": {"statuscode": r.status_code}, "data": {}}
def create_user(self, user, password, display, email, quota):
res = self.ocs(
"POST", "/ocs/v1.php/cloud/users", ADMIN,
data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota},
)
code = res["meta"]["statuscode"]
if code not in (100, 200, 102): # 102 = already exists
sys.exit("Could not create user %s: %s" % (user, res))
def dav(self, method, user, path, **kw):
auth = (user, USERS[user][0])
url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path))
return self._call(method, url, auth, **kw)
def mkdirs(self, user, path):
built = ""
for part in [p for p in path.split("/") if p]:
built += "/" + part
self.dav("MKCOL", user, built)
def put(self, user, path, data):
parent = path.rsplit("/", 1)[0]
if parent:
self.mkdirs(user, parent)
r = self.dav("PUT", user, path, data=data)
if r.status_code not in (200, 201, 204):
sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200]))
def favorite(self, user, path):
body = (
'<?xml version="1.0"?><d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
"<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set></d:propertyupdate>"
)
self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"})
def delete(self, user, path):
self.dav("DELETE", user, path)
def share(self, user, path, share_type, share_with=None, permissions=31):
data = {"path": path, "shareType": share_type, "permissions": permissions}
if share_with:
data["shareWith"] = share_with
auth = (user, USERS[user][0])
self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data)
def app_password(self, user):
auth = (user, USERS[user][0])
res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth)
token = (res.get("data") or {}).get("apppassword")
if not token:
sys.exit("Could not get an app password for %s: %s" % (user, res))
return token
def seed_server():
server = Server()
server.wait_ready()
for user, (password, display, email, quota) in USERS.items():
log("Creating user %s" % user)
server.create_user(user, password, display, email, quota)
files, favorites, trashed = build_content()
log("Uploading %d files as Alex" % len(files))
for path, data in files:
server.put("Alex", path, data)
if FILLER_MB > 0:
log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB)
server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024))
for path in favorites:
server.favorite("Alex", path)
log("Creating shares")
server.share("Alex", "/Trips", 0, "Sam", permissions=31)
server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1)
server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch."))
server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30."))
server.share("Sam", "/Team notes", 0, "Alex", permissions=31)
# Sam edits inside the folder Alex shared - gives the Activity feed a second author.
server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!"))
log("Filling the trash")
for path, data in trashed:
server.put("Alex", path, data)
for path, _ in trashed:
server.delete("Alex", path)
server.delete("Alex", "/Receipts (old)")
token = server.app_password("Alex")
print("APP_PASSWORD=" + token, flush=True)
def generate_only(target):
files, favorites, trashed = build_content()
for path, data in files + trashed:
dest = os.path.join(target, "Alex" + path)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "wb") as f:
f.write(data)
log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites)))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server")
args = parser.parse_args()
if args.generate_only:
generate_only(args.generate_only)
else:
seed_server()
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env python3
"""Makes captured screenshots Play Store ready and reports anything that isn't.
Play wants JPEG or 24-bit PNG (no alpha), each side 320-3840 px, and the long
side at most twice the short side. The captured PNGs are 32-bit RGBA, so this
flattens them to RGB in place, then checks the dimensions.
python3 tool/finalize_screenshots.py store_listing/screenshots
"""
import sys
from pathlib import Path
from PIL import Image
def main(directory):
files = sorted(Path(directory).glob("*.png"))
if not files:
sys.exit("No PNGs found in %s" % directory)
problems = 0
for path in files:
with Image.open(path) as im:
im.load()
rgb = Image.new("RGB", im.size, (255, 255, 255))
rgb.paste(im, mask=im.getchannel("A") if "A" in im.getbands() else None)
rgb.save(path, "PNG", optimize=True)
w, h = rgb.size
notes = []
if min(w, h) < 320 or max(w, h) > 3840:
notes.append("side outside 320-3840 px")
if max(w, h) > 2 * min(w, h):
notes.append("aspect ratio over 2:1")
problems += bool(notes)
print("%-32s %4dx%-4d %s" % (path.name, w, h, "; ".join(notes) or "ok"))
print("\n%d screenshots, %d with problems" % (len(files), problems))
sys.exit(1 if problems else 0)
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "store_listing/screenshots")
-273
View File
@@ -1,273 +0,0 @@
#!/usr/bin/env bash
# Generates the Play Store screenshots - without any real data.
#
# What it does, end to end:
# 1. starts a throwaway Nextcloud in Docker and fills it with fake data
# 2. creates + boots a clean Android emulator (1080x1920, demo-mode status bar)
# 3. runs integration_test/store_screenshots_test.dart against it, which
# logs in as the fake user and walks through the app's tabs and states
# 4. leaves flattened, size-checked PNGs in store_listing/screenshots/
#
# Works on Linux, macOS and Git Bash on Windows. Needs: docker, python3,
# flutter, and an Android SDK (ANDROID_HOME / ANDROID_SDK_ROOT). See
# tool/demo_server/README.md.
#
# Options (environment variables):
# SCREENSHOT_DIR output directory (store_listing/screenshots)
# ANDROID_SERIAL use this already-running device instead of the emulator
# HEADLESS=1 run the emulator without a window
# DEMO_PORT host port for the demo server (80; anything else shows
# up as ":port" in the app's header)
# DEMO_HOST show this hostname instead of "localhost" in the app,
# e.g. cloud.example.com (best effort - edits the
# emulator's hosts file, which needs a rootable image)
# KEEP_SERVER=1 leave the demo server running afterwards
# KEEP_EMULATOR=1 leave the emulator running afterwards
# SCREEN_W / SCREEN_H / SCREEN_DENSITY emulator screen (1080 / 1920 / 440);
# Play needs the long side <= 2x the short side
set -euo pipefail
cd "$(dirname "$0")/.."
REPO_ROOT="$PWD"
# Git Bash would otherwise rewrite device paths such as /system/etc/hosts.
export MSYS_NO_PATHCONV=1
AVD_NAME="${AVD_NAME:-noo_screenshots}"
API_LEVEL="${SCREENSHOT_API:-36}"
SYSTEM_IMAGE="system-images;android-${API_LEVEL};google_apis;x86_64"
SCREEN_W="${SCREEN_W:-1080}"
SCREEN_H="${SCREEN_H:-1920}"
SCREEN_DENSITY="${SCREEN_DENSITY:-440}"
OUT_DIR="${SCREENSHOT_DIR:-store_listing/screenshots}"
DEMO_PORT="${DEMO_PORT:-80}"
DEMO_HOST="${DEMO_HOST:-}"
export DEMO_PORT DEMO_HOST
log() { printf '\n==> %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# Two easy mistakes worth catching up front rather than as a confusing
# "missing prerequisites" list.
if [ "$(id -u)" = 0 ]; then
die "don't run this as root/with sudo - it would create root-owned files and use root's home for the emulator."
fi
if grep -qi microsoft /proc/version 2>/dev/null; then
die "this is WSL, which can't see your Windows Flutter/Android SDK (and can't run the emulator well). On Windows run it from Git Bash instead."
fi
# ---------------------------------------------------------------- tooling ---
# Windows env vars hold C:\... paths; convert for bash.
to_unix() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1"; else printf '%s' "$1"; fi; }
SDK_ROOT=""
for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" \
"$HOME/Library/Android/sdk" "${LOCALAPPDATA:-}/Android/Sdk"; do
if [ -n "$candidate" ] && [ -d "$(to_unix "$candidate")" ]; then
SDK_ROOT="$(to_unix "$candidate")"
break
fi
done
# Finds a tool by name on PATH or in the SDK, including Windows .bat/.exe.
find_tool() {
local name="$1" dir ext
for ext in "" .bat .exe; do
if command -v "$name$ext" >/dev/null 2>&1; then command -v "$name$ext"; return 0; fi
done
for dir in "$SDK_ROOT/cmdline-tools/latest/bin" "$SDK_ROOT/platform-tools" "$SDK_ROOT/emulator"; do
for ext in "" .bat .exe; do
if [ -x "$dir/$name$ext" ]; then printf '%s' "$dir/$name$ext"; return 0; fi
done
done
return 1
}
missing=()
require() { # <command> <how to get it>
command -v "$1" >/dev/null 2>&1 || missing+=("$1 - $2")
}
require docker "install Docker (Desktop on Windows/macOS)"
require flutter "install Flutter and put it on PATH"
PY="$(command -v python3 || command -v python || true)"
[ -n "$PY" ] || missing+=("python3 - install Python 3")
ADB="$(find_tool adb || true)"
[ -n "$ADB" ] || missing+=("adb - install Android platform-tools (or set ANDROID_HOME)")
if [ -z "${ANDROID_SERIAL:-}" ]; then
SDKMANAGER="$(find_tool sdkmanager || true)"
AVDMANAGER="$(find_tool avdmanager || true)"
[ -n "$SDKMANAGER" ] && [ -n "$AVDMANAGER" ] ||
missing+=("sdkmanager/avdmanager - install the Android cmdline-tools (or set ANDROID_HOME)")
fi
if [ "${#missing[@]}" -gt 0 ]; then
printf 'Missing prerequisites:\n' >&2
printf ' - %s\n' "${missing[@]}" >&2
exit 1
fi
if docker compose version >/dev/null 2>&1; then
DC=(docker compose)
else
DC=(docker-compose)
fi
DC+=(-f tool/demo_server/docker-compose.yml)
adb() { "$ADB" "$@"; }
# Python with Pillow + requests: use the system one if it has them, else a
# local virtualenv (tool/demo_server/.venv, gitignored).
if ! "$PY" -c 'import PIL, requests' >/dev/null 2>&1; then
log "Setting up a Python virtualenv (Pillow, requests)"
"$PY" -m venv tool/demo_server/.venv
if [ -x tool/demo_server/.venv/bin/python ]; then
PY="$REPO_ROOT/tool/demo_server/.venv/bin/python"
else
PY="$REPO_ROOT/tool/demo_server/.venv/Scripts/python"
fi
"$PY" -m pip install -q -r tool/demo_server/requirements.txt
fi
# ---------------------------------------------------------------- cleanup ---
STARTED_EMULATOR=0
EMULATOR_PID=""
cleanup() {
local code=$?
set +e
if [ -n "${SERIAL:-}" ]; then
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1
adb -s "$SERIAL" reverse --remove-all >/dev/null 2>&1
fi
if [ "$STARTED_EMULATOR" = 1 ] && [ "${KEEP_EMULATOR:-0}" != 1 ]; then
adb -s "$SERIAL" emu kill >/dev/null 2>&1
fi
if [ "${KEEP_SERVER:-0}" != 1 ]; then
"${DC[@]}" down -v >/dev/null 2>&1
fi
exit "$code"
}
trap cleanup EXIT
# ---------------------------------------------------------- demo server ---
log "Starting a fresh demo Nextcloud (port $DEMO_PORT)"
"${DC[@]}" down -v >/dev/null 2>&1 || true
"${DC[@]}" up -d
log "Seeding fake data"
APP_PASSWORD="$(DEMO_BASE_URL="http://localhost:$DEMO_PORT" "$PY" tool/demo_server/seed.py | grep '^APP_PASSWORD=' | tail -n1 | cut -d= -f2-)"
[ -n "$APP_PASSWORD" ] || die "the seed script did not return an app password"
# ---------------------------------------------------------------- device ---
if [ -n "${ANDROID_SERIAL:-}" ]; then
SERIAL="$ANDROID_SERIAL"
log "Using existing device $SERIAL"
else
log "Preparing the emulator"
[ -n "$SDK_ROOT" ] || die "set ANDROID_HOME to your Android SDK"
EMULATOR="$(find_tool emulator || true)"
if [ -z "$EMULATOR" ] || ! [ -d "$SDK_ROOT/system-images/android-${API_LEVEL}/google_apis/x86_64" ]; then
log "Installing the emulator + system image (one-off, a few GB)"
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
"$SDKMANAGER" "emulator" "platform-tools" "$SYSTEM_IMAGE"
EMULATOR="$(find_tool emulator)"
fi
# avdmanager also prints a harmless "Could not load devices from
# .../devices.xml" error for system images that ship no devices.xml, so its
# output is hidden and success is judged by whether the AVD exists.
echo no | "$AVDMANAGER" create avd --force -n "$AVD_NAME" -k "$SYSTEM_IMAGE" -d pixel_6 >/dev/null 2>&1 || true
AVD_DIR="$(to_unix "${ANDROID_AVD_HOME:-$HOME/.android/avd}")/$AVD_NAME.avd"
[ -d "$AVD_DIR" ] || die "could not find the AVD at $AVD_DIR (set ANDROID_AVD_HOME)"
set_ini() { # <key> <value>: replace or append in the AVD's config.ini
if grep -q "^$1=" "$AVD_DIR/config.ini"; then
sed -i.bak "s|^$1=.*|$1=$2|" "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
else
echo "$1=$2" >> "$AVD_DIR/config.ini"
fi
}
sed -i.bak '/^skin\.path=/d' "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
set_ini skin.name "${SCREEN_W}x${SCREEN_H}"
set_ini hw.lcd.width "$SCREEN_W"
set_ini hw.lcd.height "$SCREEN_H"
set_ini hw.lcd.density "$SCREEN_DENSITY"
log "Booting the emulator"
EMULATOR_FLAGS=(-avd "$AVD_NAME" -no-snapshot -wipe-data -no-audio -no-boot-anim -gpu swiftshader_indirect)
[ "${HEADLESS:-0}" = 1 ] && EMULATOR_FLAGS+=(-no-window)
"$EMULATOR" "${EMULATOR_FLAGS[@]}" >/tmp/noo_emulator.log 2>&1 &
EMULATOR_PID=$!
STARTED_EMULATOR=1
adb wait-for-device
for _ in $(seq 1 150); do
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break
sleep 2
done
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] || die "the emulator did not finish booting (see /tmp/noo_emulator.log)"
SERIAL="$(adb devices | awk '/^emulator-/{print $1; exit}')"
fi
export ANDROID_SERIAL="$SERIAL"
log "Configuring the device"
adb shell input keyevent 82 >/dev/null 2>&1 || true # dismiss keyguard
adb shell svc power stayon true >/dev/null 2>&1 || true # never sleep mid-run
# Make the demo server reachable from the device as localhost. Binding a port
# below 1024 (the default 80) on the device needs root - the google_apis
# emulator images allow `adb root` - otherwise fall back to a high device
# port, which then shows up as ":8080" in the app.
DEVICE_PORT="$DEMO_PORT"
if [ "$DEMO_PORT" -lt 1024 ]; then
adb root >/dev/null 2>&1 || true
sleep 2
adb wait-for-device
fi
if ! adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" >/dev/null 2>&1; then
DEVICE_PORT=8080
printf 'note: could not bind port %s on the device (no root); using %s instead\n' "$DEMO_PORT" "$DEVICE_PORT" >&2
adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" || die "adb reverse failed"
fi
SERVER_HOST="localhost"
if [ -n "$DEMO_HOST" ]; then
# Best effort: make DEMO_HOST resolve to the (reverse-forwarded) loopback.
if adb root >/dev/null 2>&1 && sleep 2 && adb remount >/dev/null 2>&1 &&
adb shell "echo '127.0.0.1 $DEMO_HOST' >> /system/etc/hosts" >/dev/null 2>&1; then
SERVER_HOST="$DEMO_HOST"
else
printf 'warning: could not edit the device hosts file; showing "localhost" instead of %s\n' "$DEMO_HOST" >&2
fi
fi
SERVER_URL="http://$SERVER_HOST"
[ "$DEVICE_PORT" = 80 ] || SERVER_URL="$SERVER_URL:$DEVICE_PORT"
# Clean status bar: 12:00, full battery, full Wi-Fi, no notification icons.
adb shell settings put global sysui_demo_allowed 1
demo() { adb shell am broadcast -a com.android.systemui.demo -e command "$@" >/dev/null; }
demo enter
demo clock -e hhmm 1200
demo battery -e level 100 -e plugged false
demo network -e wifi show -e level 4 -e fully true -e mobile hide
demo notifications -e visible false
# --------------------------------------------------------------- capture ---
log "Capturing screenshots (this drives the app for a few minutes)"
rm -rf "$OUT_DIR"
mkdir -p "$OUT_DIR"
SCREENSHOT_DIR="$OUT_DIR" flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/store_screenshots_test.dart \
-d "$SERIAL" \
--dart-define="DEMO_SERVER_URL=$SERVER_URL" \
--dart-define="DEMO_USERNAME=Alex" \
--dart-define="DEMO_APP_PASSWORD=$APP_PASSWORD"
log "Making them Play Store ready"
"$PY" tool/finalize_screenshots.py "$OUT_DIR"
log "Done: $OUT_DIR"
printf '\nBefore uploading, LOOK at every image for anything real: the server\n' >&2
printf 'address/username in the header and Settings, notification icons, names.\n' >&2