64 Commits
Author SHA1 Message Date
Ayushya AmitabhandClaude Sonnet 5.5 660d0bc869 Store assets: App Store header, search results, iPhone 6.3 and foldable sizes
Check PR / check (pull_request) Successful in 59s
Adds ios/header, ios/search-results, ios/iphone-6.3in and ios/iphone-fold-*
to compose.py and the README upload table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 22:58:49 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5837464581 Store assets: landscape tablet screenshots (iPad, Android 7/10-inch)
Captured from the app's real sidebar shell at 1024x768; adds landscape
compositions to compose.py and the README upload table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 21:46:29 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 11b3d535a9 Store listing assets for the five website features (accounts, locks, offline sync, sharing, external storage)
iPhone, iPad, Android phone and 7/10-inch tablet screenshots, Play feature
graphic and icon, and draft listing copy, generated from real app widgets.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 21:32:01 -04:00
Ayushya Amitabh d92bf04a7d Add action bar preview and all swipe actions; rename App lock 2026-10-07 21:04:50 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 86e5e599b6 CI: pin iOS signing certificate by SHA-1 and log CI keychain identities
Build iOS / build (push) Successful in 4m0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 19:36:29 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0ea98363de CI: split release tags - RC-Android-* (APK), RC-iOS-* (iOS), Release-* (both)
Build iOS / build (push) Failing after 1m55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 19:15:29 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 ca4afb2ed7 CI: signed iOS build and TestFlight upload on Release tags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 19:04:39 -04:00
Ayushya Amitabh f6407c447e Fix bottom avatar popup account ordering on Android
Build APK / build (push) Successful in 5m25s
2026-10-07 03:14:14 -04:00
Ayushya Amitabh ce2273c9a6 Refine iOS navigation, media controls, and background sync
Build APK / build (push) Successful in 5m50s
2026-10-06 23:18:50 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 c5b46fd493 Avatar menu: same side inset as the Files list rows
The card was inset 16px from each screen edge; the list rows are 12px, so it
read as narrower. Now NooSpace.sm, with a test on the account row's width.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 21:02:21 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 8805bafb97 iOS: device sync (Swift port of the Android engine) behind the sync_service channel
PROPFIND walk / diff / GET / PUT mirror with conflicts, per-account state,
BGTaskScheduler background runs, and conflict notifications with Keep local /
Use server. Reviewed by Codex against the Kotlin original; its data-loss
findings are fixed and each has a regression test (fake-server end-to-end
tests, 68 Swift tests total): truncated/unusable PROPFIND answers are never a
manifest, unreadable state aborts the run, server paths can't leave the mirror,
case/Unicode collisions are skipped, atomic downloads, a locally edited file
deleted on the server is kept, '/' scope matching, serialised removal,
tracked/cancellable background runs, per-account conflicts, and sign-out vs
background-off credentials (cancel gains a 'forget' flag).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 21:01:08 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 1634d34341 Viewer: round icon buttons with backgrounds; plain text follows Edit -> Save too
Edit/Preview and Save are now compact (36px) icon-only NooButtons - primary when
alone, Edit/Preview secondary beside a primary Save, a spinner circle while
saving. Plain text opens read-only like Markdown and gets the same toggle.
NooButton gains an opt-in iconOnly mode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:50:26 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5fa20ff3e9 Media viewer: Edit/Preview and Save move to the top bar
A TextPreviewController (owned by the viewer, bound by MediaTextPreview)
mirrors the editing/dirty/saving state out so the top bar draws the buttons;
Save shows a spinner while saving. Located and tested via a Codex consult
(its end-to-end widget test, with the provider fixed and the saving state
adapted); the controller design replaces its builder-wrapping patch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:38:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 4ab6c5d85c Share sheet: ask to unlock on every gated action, like the app
The sheet remembered one unlock for its whole lifetime, so later account picks
and hidden toggles skipped Face ID. Each switch / each turning-on of hidden
folders now prompts again; one action needing both is a single prompt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:24:20 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 02d9d799a6 iOS: authenticate each request by its own header only (no cookies/credential cache)
Two accounts can share one server; the Share Extension's folder listing and
the upload sessions now ignore cookies and stored credentials so one account's
session can never answer for another. Logs each PROPFIND (user, path, status).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:18:20 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 02d612bfec Share sheet: Hidden and Storage toggles, plus diagnostics for the missing accounts
Two menu chips start at the account's app settings (hidden filter, storage
scope - now published per account), hidden-on asks for the app's unlock, and
external storage is detected via nc:mount-type incl. everything under a
mount. Adds logging for which accounts get published and why one is skipped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:15:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 22a6602f08 Security locks work independently; other-account avatars flush right in the avatar menu
Login lock, lock account switching and lock hidden files no longer depend on
each other: passGate prompts on its own flag, the two sub-locks can be set
with login lock off, and disabling login lock leaves them alone. Turning any
lock on or off asks for device auth (on also checks the device can). The iOS
Share Extension's unlock rules follow. The avatar dropdown's additional
accounts lose the 44px spacer so their avatars sit at the right edge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 20:07:28 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 27c8ac12f2 Share Extension: remove the legacy single-account keychain item on clear
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:59:38 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 a37524e959 Share Extension: account picker first, hidden folders follow the app's filter + unlock
The app now publishes every uploadable account, the active one, the three
lock settings and each account's own hidden-files filter (ShareAccountSync).
The sheet lists accounts first when there are several, asks for unlock to use
a non-active account (login lock + account-switching lock), and shows hidden
folders per the account's app setting behind the hidden-files unlock.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:59:29 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0cda4a1241 Frosted bottom bar: adjustable blur and opacity with Less/Default/More presets
Settings > Appearance shows a preset control plus Blur and Opacity sliders
(new NooSlider) while frosted glass is on; values persist and feed
NooBottomBar.frostedBlur/frostedOpacity. Default matches the old fixed look.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:46:54 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 0c063c1fb4 iOS transfers: 'Uploading...' then 'Uploaded' notification, and count share uploads
The Share Extension's session had no delegate, so a fast upload finished
unseen and the invalidated session was discarded before the app could hear of
it - no summary notification. Results are now handled by a shared delegate in
whichever process is alive, started/finished notifications replace each
other in place, and the app refreshes the folder the extension uploaded to.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:44:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 afc9b90963 iOS Share Extension: pick the destination inside the share sheet
SwiftUI folder picker in the extension (WebDAV PROPFIND), account shared via
a Keychain group, upload on a background session that outlives the extension
and is finished by the app. The inbox + notification hand-off remains as the
fallback (no account / 'choose later'). Shared code moves to ios/Shared.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:36:04 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 525504bc47 iOS: Share Extension (share to Noo) via an App Group inbox
ShareExtension target copies shared files into group.dev.ayushya.noo and
leaves a manifest; the app consumes it on launch/activation through the
existing share_intent channel into the destination picker. Extension is
embedded before Flutter's script phases; versions follow FLUTTER_BUILD_*.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:23:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 8b86aa8f34 iOS: show transfer notifications as banners while the app is open
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:15:59 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 469e57cca7 iOS: native upload and download services (background URLSession)
Swift implementation of the upload_service/download_service channels:
background PUT/GET against the account's WebDAV root, one summary local
notification per batch, Downloads visible in the Files app. Pure helpers
covered by RunnerTests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:12:15 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d008185f98 iOS: degrade gracefully where the Android-only native services are missing
Share/pick/sync calls and streams no longer throw MissingPluginException at
startup; upload/download/sync-now show 'X isn't available on this platform
yet'. Offline/sync mirror dir no longer uses the Android-only external dir.
Info.plist: Face ID and photo library usage strings.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 19:06:19 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 813c613249 Dynamic color: keep card surfaces lighter than the page
Build APK / build (push) Successful in 5m39s
In light mode surfaceContainerLow is slightly darker than the OS surface bg
maps to, so the selection bar and other cards blended into the page. Use
surfaceContainerLowest (light) / surfaceContainer (dark) for NooColors.surface.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:53:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 c0a67f3efe iOS: replace default Flutter app icons with the Noo icon
Generated from assets/icon/app_icon.png, opaque RGB at every size in the set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:42:30 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 5fbac333d5 Filter sheets: 'Type' label above the type control; shorten 'All + ...' to 'All'
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:38:37 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 3c60f8c709 Filters: three-way hidden files and external storage
Hidden: hide / only hidden / all + hidden. External: cloud only / only
external / all + external; with 'all' Files shows the external items in a
collapsible section. Old show-hidden bool prefs are still read as fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:34:11 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 944b87b054 Bottom bar: add frosted glass option (Settings > Appearance)
Translucent fill over a backdrop blur, both platforms and both bar styles.
Frosted bars draw over an extended body, so the shell's extendBody and the
tab clearance now key off NooBottomBar.drawsBehindBody.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:16:27 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 d6448984d9 Files: don't context.watch from the item tap handler
watch outside build asserts in debug builds, so taps on rows silently did
nothing in debug (release strips the assert, which hid it on Android).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 22:13:45 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 52abd16983 Floating bottom bar: use Android row (icon-only idle tabs) on iOS too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 21:36:35 -04:00
Ayushya AmitabhandClaude Sonnet 5.5 cb66b4a0ad iOS: raise deployment target to 15.0 (required by current Flutter)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 20:38:17 -04:00
ayushyaandClaude Code da3b8a4f05 README: explain license choice and Gitea source of truth
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 20:34:08 -04:00
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
ayushyaandClaude Code 8aa7015c3f Set app version to 0.0.1+2
Build App Bundle / build (push) Successful in 5m38s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 10:18:21 -04:00
ayushya 88abca647d Move Website source 2026-10-01 10:13:33 -04:00
ayushya 65b772d6e7 Merge pull request 'Set app version to 0.0.1' (#2) from noo-design-system-rebuild into main
Reviewed-on: #2
2026-10-01 09:37:12 -04:00
ayushyaandClaude Code 8179bf9683 Set app version to 0.0.1
Build App Bundle / build (push) Successful in 5m21s
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 09:35:45 -04:00
ayushya af69e5e751 Merge pull request 'Noo design system rebuild' (#1) from noo-design-system-rebuild into main
Build App Bundle / build (push) Successful in 5m45s
Reviewed-on: #1
2026-10-01 09:23:57 -04:00
ayushyaandClaude Code 1a232fc71a Drop Play Console upload from the Release workflow
The Release-* workflow now only builds Noo-<version>.aab and attaches it to
the Gitea release. The now-unused upload script is removed with it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 09:14:26 -04:00
ayushyaandClaude Code 16aadda7b8 Make the top-bar search launcher fill the gap beside the tab title
Build APK / build (push) Successful in 5m27s
The title was a Flexible and the search an Expanded, so both defaulted to
flex 1 and split the free space evenly. The title is now a non-flex child
capped at 40% of the width, leaving the search all remaining space.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 07:44:33 -04:00
ayushyaandClaude Sonnet 5 fb4829d035 Fix avatar menu's missing border and put the tab title beside search
Build APK / build (push) Successful in 5m36s
- The avatar menu card's border was going missing wherever an opaque
  row sat against it (every corner but the header's) - a Container
  combining `border` with its own `clipBehavior` paints the border as
  part of the outer decoration, then the clipped child on top right up
  to the same boundary, with no gap for the border's stroke to show
  through. Fixed with the standard pattern: no clipBehavior on the
  bordered Container, a 1px-inset ClipRRect around the filled, clipped
  content instead.
- NooTopBar.androidTitleTrailing (renamed from androidTitleReplacement)
  now sits beside the tab title instead of replacing it - the title
  keeps its natural width (Flexible, ellipsizing only if truly out of
  room) with the inline search bar taking the remaining space to its
  right, rather than the title disappearing whenever search moves into
  the top bar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-01 02:16:30 -04:00
ayushyaandClaude Sonnet 5 9f47d0a3e3 Fix status-bar overlap on scroll, and reposition/elevate the avatar menu
- Every tab now wraps its CustomScrollView in SafeArea(top: true) - the
  pinned sort/filter row was riding up underneath the status bar once
  the floating top bar above it fully collapsed, since that bar's own
  internal SafeArea only reserved the inset while it had some height
  left to put it in. Reserving it outside the scrolling region entirely
  fixes this regardless of the floating header's state. Caught live on
  device and backed by a new regression test.
- The avatar menu now covers the top bar (title included) instead of
  sitting below it - positioned off just the status-bar inset rather
  than the top bar's full height on top of that.
- The avatar menu's card now carries a second, tighter contact shadow
  alongside nooDialogShadow - that shadow alone was essentially
  invisible on a small card over a dark theme's near-black background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 22:59:19 -04:00
ayushyaandClaude Sonnet 5 e6d96a6f9f Add avatar-menu navigation style and inline Android search bar
- New Settings > Appearance > "Navigation menu" toggle
  (SettingsController.navMenuStyle, NooNavMenuStyle.drawer/avatarMenu):
  the hamburger-opens-a-Drawer pattern stays the default, or the avatar
  button opens a full-width dropdown (showAvatarMenu) instead, holding
  the same hidden-tabs + Settings content the drawer does. Removes one
  of the two top-corner targets the shell asks a thumb to reach, with
  no permanent chrome added.
- NooTopBar gains androidTitleReplacement so Android's top bar can swap
  its title for an inline search-field-styled launcher
  (ShellSearchLauncher) when search isn't in the bottom bar, instead of
  a bare search icon beside it.
- ShellAvatarButton's onTap/label are now overridable so AppTopBar can
  repoint it per the new setting without this file depending on
  avatar_menu.dart (which already depends on it for shared helpers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 22:25:04 -04:00
ayushyaandClaude Sonnet 5 840ec4c301 Fix duplicate tab_state_slivers.dart import left over from the merge
Both the bottom-clearance and the top-bar-sliver work independently
added this import to files_view.dart and favorites_view.dart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:32:15 -04:00
ayushyaandClaude Sonnet 5 aeea4847b4 Merge: make the shell top bar hide/reveal with scroll, per tab
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:31:11 -04:00
ayushyaandClaude Sonnet 5 43f36ed532 Merge: restructure mobile Settings into a two-level category menu
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:30:45 -04:00
ayushyaandClaude Sonnet 5 5e5acc3fc6 Polish floating bottom bar: elevation, height, overlap, and FAB shape
- Give the floating bar and search satellite the app's one shadow
  (nooDialogShadow) plus a smaller, tighter height on both platforms.
- Let every tab's scrollable list compute its own bottom clearance
  (bottomBarClearance) instead of a flat 100px, so the floating bar no
  longer overlaps the last row/card.
- Square the Android upload FAB off to a rounded-square radius when the
  bottom bar is attached, keeping it fully round only when floating.
- Let a swipe on a file row trigger its action directly once dragged
  past 1.8x the reveal width, instead of requiring a second tap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:30:14 -04:00
ayushyaandClaude Sonnet 5 5dea9038f7 Make the shell top bar hide/reveal with scroll, per tab
Each regular tab (Files/Photos/Favorites/Shares/Recent/Activity/Trash)
now plants its own AppTopBar as the first sliver in its own
CustomScrollView, via a new topBarSliver() helper (tab_state_slivers.dart)
wrapping the framework's SliverFloatingHeader, instead of one shared
instance living in Scaffold.appBar.

This gives each tab's top bar Material's native floating-app-bar
behavior - tied to that tab's own ScrollController - scrolling away as
the list scrolls down and reappearing the moment the drag reverses,
not only once scrolled all the way back to the top, which a single
fixed Scaffold.appBar (or the "put it in a SliverToBoxAdapter" escape
hatch NooTopBar's doc comment already mentioned) can't do. It sits
above each tab's own pinned in-content sticky header (the sort/filter
row, or the selection bar that replaces it), which keeps working
unchanged underneath it.

main.dart builds a separate, correctly-labelled AppTopBar per tab
(rather than one for "whichever tab is selected") and passes it through
buildAppTabView's new optional topBar parameter; it's null on desktop
(which keeps its own NooToolbar) and while picking, matching the old
Scaffold.appBar: pickRequest == null guard.

SliverFloatingHeader sizes itself from the bar's own natural layout
(like SliverToBoxAdapter) rather than a fixed extent declared up front,
so NooTopBar's own internal SafeArea keeps handling the status-bar inset
correctly with no extra height math needed on this side.

Adds a widget test (test/widgets/tabs/tab_state_slivers_test.dart)
driving a raw gesture mid-drag to verify the reveal-follows-finger
behavior, since a single tester.drag() only pumps after the gesture -
including release - has already finished, which doesn't exercise the
live per-frame reveal this change depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:28:36 -04:00
ayushyaandClaude Sonnet 5 a8b14b8744 Restructure mobile Settings into a two-level category menu
On mobile, AccountView used to render all 9 Settings sections inline
in one long, individually-collapsible column (_MobileList). Replace
that with a native-style menu: the account card stays pinned at the
top, and every other section becomes a NooSettingsRow in a
NooGroupedList that pushes a dedicated screen (NooTopBar +
NooTopBarBack) holding just that section's content. This removes the
scroll-depth problem outright instead of working around it with
per-section collapsing, so SettingsSection no longer needs
NooGroupedList's collapsible mode on mobile. Desktop's 2-column grid
is unchanged - it already shows every section at once.

Audited every lib/widgets/settings/*.dart file for the reported
"description and controls are flipped" row-layout bug: every row goes
through NooSettingsRow directly, or - for the few hand-rolled rows
(_SavedAccountRow, _ThemeRow/_BottomBarStyleRow, _CacheIntervalRow) -
preserves its icon/description-then-control order (the stacked
control-below-label shape used by _ThemeRow and _CacheIntervalRow is
an intentional, spec'd variant, not a flip). Found no instance of the
bug; no fix was needed.

Updates DESIGN_SYSTEM.md's Settings recipe and styling.md's
NooGroupedList notes to describe the new menu-then-pushed-screen
pattern, and adds a widget test covering the category menu and the
push/pop navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 16:15:32 -04:00
ayushyaandClaude Sonnet 5 5f4caa2a3b Fix floating bottom bar filling the screen when search is enabled
Build APK / build (push) Successful in 5m40s
The Row wrapping the pill + search satellite used
crossAxisAlignment.stretch, but Scaffold.bottomNavigationBar gives its
child a loose (unbounded-max) height constraint - stretch on an
unbounded cross axis blows up to fill all available space in
profile/release builds (the debug assertion that would catch it is
stripped there), same class of bug files_controls_row.dart hit earlier
from a different cause. Both children already sized themselves
explicitly, so stretch was never actually needed - wrapping the Row in
a fixed-height SizedBox instead fixes it.

Added a regression test that reproduces this inside a real Scaffold
(the bare SizedBox host the other tests use gives a bounded height and
doesn't trigger it) - confirmed it fails without the fix (height 600,
the full test viewport) and passes with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:59:52 -04:00
ayushyaandClaude Sonnet 5 4927416277 Add product website (deploy target: noo.ayushya.dev)
A single-page, static site under website/ - no build step, no
framework, just HTML/CSS/vanilla JS plus the two Google Fonts the app
already uses. Colors, radii, type scale and motion timing are copied
from lib/theme/design_tokens.dart so it reads as the same product as
the app, not a separate marketing skin.

Sections: hero with an exploded, parallaxing stack of the app's own UI
pieces (file row, photo grid, accent swatches, a toggle, the floating
bar) that drift apart on scroll and tilt toward the pointer; feature
highlights; a Customize section with two live interactive demos
(accent color, floating vs attached bottom bar); a privacy section
covering Login Flow v2; and a closing CTA.

GitHub/download links are placeholders (documented in website/README.md)
until real release URLs exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:25:18 -04:00
ayushyaandClaude Sonnet 5 58312511c5 Add "Search in bottom bar" to Settings
Build APK / build (push) Successful in 5m30s
New Settings > Appearance toggle moves Search into the bottom bar and
out of the top bar, so there's only one search entry point on screen.
Tapping it pushes SearchView same as before - it's never highlighted,
since it isn't a real destination.

- Attached: Search becomes the row's last item, styled like any other
  tab but permanently idle.
- Floating: Search gets its own separate, always fully round satellite
  circle beside the bar instead.

Enabling it costs one regular tab: SettingsController.maxVisibleTabs
is now a dynamic getter (defaultMaxVisibleTabs - 1 while the toggle is
on) instead of the old flat constant, re-enforced immediately if the
user already has all 5 slots filled - the same cleanup path used for
a fresh install or an app update that added a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:19:13 -04:00
ayushyaandClaude Sonnet 5 a70be19f80 Add a floating bottom bar style, user-configurable in Settings
Settings > Appearance > "Bottom bar" now switches between the
original edge-to-edge bar and a floating one - inset 16px from both
side edges and clear of the bottom safe area, 28px corners (the same
radius the sheet top/drawer edge use), a 1px line border standing in
for elevation instead of a shadow (product UI stays flat). Persisted
via NooBottomBarStyle, defaulting to the existing attached behavior.

On Android, floating's idle tabs drop their reserved label space -
with no label to leave room for, the icon centers in the whole button
and renders a touch bigger (27 vs 24px) instead of sitting high with a
gap held open under it; the active tab's icon/pill/label position is
unchanged. iOS's tab content is unchanged either way, only the
container.

main.dart sets Scaffold.extendBody while floating so the body scrolls
behind the bar's transparent margin instead of stopping short of it,
matching how the design canvas explored it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:21:13 -04:00
288 changed files with 16579 additions and 2405 deletions
+42 -10
View File
@@ -57,15 +57,16 @@ new provider instance):
saved-accounts list, which one is active, `sessionGeneration` — see
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
controllers — constructed after `SessionController` and unable to hold a
forward reference to it — can react to login/logout/account-switch
without a circular dependency.
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
global UI prefs independent of login state: theme mode/seed color/dynamic
color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab
color, bottom-bar opacity/blur, avatar and selection action-bar positions,
tap-tab-to-scroll-top, seek bar style, tab
order/visibility/default (`requestedTab`/`requestTab`/
`consumeRequestedTab`), swipe actions.
- [`FilesController`](../../lib/providers/files_controller.dart) — the
@@ -275,21 +276,52 @@ bare system prompt gives no context on its own.
`MainShellView` builds its chrome from the Noo nav kit
(`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`
for *every* tab (previously only Files had shell-level top chrome, with
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
large title, an inline search field, a `plus` action on Files only (no
other tab has a create/upload flow), and the account avatar; Android
large title with Upload and the top account avatar on the same row,
and an inline search field below; Android
gets a compact title row with `search`/avatar actions, relying on an
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
icon for upload. `BottomNavBar` (`widgets/bottom_nav_bar.dart`) adapts
the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
extended `NooFab` instead of a top-bar upload button. Both use the same
size preference: Auto expands on Files/Photos and collapses elsewhere,
Mini always collapses, and Expanded always shows Upload. Rather than one
shared instance in `Scaffold.appBar`,
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
tab) and each tab plants its own as the first sliver in its own
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
wrapping it in the framework's `SliverFloatingHeader`) instead of passing
it to `Scaffold.appBar` - see that file's doc comment for why (Material's
native "floating" app bar behavior, tied to that tab's own
`ScrollController`: scrolls away as the list scrolls down and reappears
the moment the drag reverses, not only once scrolled back to the top).
`topBar` is null (no top bar rendered) on desktop and while picking,
matching `Scaffold.appBar`'s old `pickRequest == null` guard - see
`buildAppTabView`'s doc comment. It sits above each tab's own pinned
in-content header (the sort/filter controls row, or Files/Photos'
selection bar - see below); the two float/scroll independently - each
tab also wraps its whole `CustomScrollView` in `SafeArea(top: true,
bottom: false, ...)` so that pinned header stays clear of the status bar
once the floating top bar above it fully collapses (see
`topBarSliver`'s own doc comment for why that reservation can't live
inside the top bar itself). `BottomNavBar` (`widgets/bottom_nav_bar.dart`)
adapts the pinned `AppTab`s onto `NooBottomBar`. `AppDrawer`
(`widgets/app_drawer.dart`) builds a `NooDrawer`: account block, storage
meter, a "More" list of the hidden tabs, Settings, and an "Edit tabs"
link (opens Settings - there's no in-page anchor to scroll to its Tabs
section yet).
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
"Navigation menu") offers an alternative to the hamburger/drawer pair:
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
card from the top or bottom avatar; `AvatarNavigationBody` moves only
content for the top popup. The bottom popup overlays content. Floating
mode moves the avatar into the popup and hands its satellite slot to
Search; attached mode keeps the avatar and Search in the full-width bar
and expands the popup upward from it. Position is a global setting.
See `styling.md` for motion and dismissal behavior.
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
(tab title, search, an "Upload" action on Files/Photos) over the same
+42 -19
View File
@@ -109,10 +109,18 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
- An extended pill: 56px tall, accent fill, labelled "Upload".
- Sits 16px from the right and bottom edges of the content area.
- Stays mounted on every tab; it's only the extended label that's tied to
Files/Photos. Elsewhere it collapses to an icon-only 56px circle (still
tappable - it always targets the Files tab's current folder), animating
the width/label change rather than the button popping fully in/out as
the Scaffold's default FAB transition would on every tab switch.
Files/Photos. Elsewhere it collapses to an icon-only 56px circle or
rounded square (see below) - still tappable, it always targets the Files
tab's current folder - animating the width/label change rather than the
button popping fully in/out as the Scaffold's default FAB transition
would on every tab switch.
- Corner radius follows the bottom bar style (Settings → Appearance):
fully round (`pill`, 999) when the bar is floating, matching that mode's
other already-rounder elements (the bar itself at 28px, its search
satellite a full circle); a rounded square (`card`, 20) when the bar is
attached, to suit that mode's flatter, edge-to-edge corners. Same radius
whether the FAB is collapsed to a circle/square or expanded to the full
pill shape.
**Chip**
- A pill, 32–34px tall, 12px horizontal padding, Label 13–14.
@@ -160,9 +168,10 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
- Shared: `users` in fg-3.
**Swipe action**
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent.
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; all other actions are white on accent.
- The block has a 20px icon above a 12/600 label.
- Swipe actions are set in Settings → Swipe on a file.
- Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it.
- Swipe actions are set in Settings → Swipe on a file. Both directions offer None and every action-bar action: Favorite, Share, Download, Delete, Copy, Move, Rename, Sync to device, Details. They use the same single-item handlers; Delete still asks for confirmation.
**Selection action bar** (Files, Photos, Favorites - anywhere with
multi-select)
@@ -271,12 +280,16 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
| Element | iOS | Android |
|---|---|---|
| Status bar | 54px, Dynamic Island | 40px |
| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar |
| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, avatar - see "Search" below for what replaces `search` there, and "Navigation menu" for `menu` |
| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. |
| Upload | `plus` in the nav bar | Extended FAB |
| Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. Product UI otherwise stays flat (see 1.4), but this is the one other place - besides desktop dialogs - that gets the app's one shadow: a 1px `line` border plus the dialog shadow, since a bar that's genuinely floating above scrolling content needs to read as elevated. Row height drops (iOS 50, same as attached; Android 64 vs 80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (25 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it - which means every tab's scrollable list has to pad its own bottom enough to clear the bar's footprint, since nothing does that for it automatically once the body draws behind the bar. The Android FAB stays fully round in this mode (see "Upload" below), consistent with the bar's own rounder shape. |
| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. |
| Search (top bar, when not in the bottom bar) | A real inline search field (`NooSearchField`, non-editable launcher) below the large title | A search-field-styled launcher takes the title's own flexible slot instead of a bare `search` icon beside it - no second row to put it under the way iOS has |
| Navigation menu (Settings → Appearance → "Navigation menu", both platforms) | `drawer` (default): the `menu` icon opens the left `Drawer` (hidden tabs + Settings), unchanged. `avatarMenu`: no `menu` icon at all - the avatar button opens a dropdown instead (`showAvatarMenu`), anchored under the top bar, full width minus the same 16px gutter every content row uses, with the same hidden-tabs + Settings content the drawer holds (not its storage bar - this is a quick menu, not the drawer's full account summary). The avatar sits in the exact same (right-end) position either way, and the dropdown's own header repeats it there too, so the open menu reads as having grown out of the button that opened it. Not a mandatory replacement: this halves the top-corner surface a thumb has to reach (from `menu` + avatar down to avatar alone) without costing any permanent screen width the way an earlier side-rail exploration would have. |
| Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
| App lock label | "App lock" | "App lock" |
### Desktop
@@ -353,18 +366,28 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
priority order for the Selection action bar's bulk actions (favorite,
share, download, delete, copy, move, rename, sync, details) - the
first few (3 mobile / 4 desktop) land in the bar's fixed inline
slots, the rest sit behind "More". See §2 "Selection action bar".
slots, the rest sit behind "More". A live preview above the list uses
the actual bar and updates on reorder, including its More sheet. Preview
actions show feedback without touching files. See §2 "Selection action bar".
9. Swipe on a file
Mobile uses one column of grouped lists, each individually collapsible
(tap its label, expanded by default - `NooGroupedList`'s `collapsible`
param) so the full list can be collapsed down instead of needing a
separate way to navigate it; an earlier version had a trailing jump rail
(one small icon per section, pinned where the scrollbar would sit)
instead, dropped for adding a second, redundant navigation method without
shortening the page. Desktop uses a 2-column grid of cards with a 1px
line and radius 20, wide enough to see most sections without scrolling,
so it gets neither.
Mobile is a two-level menu, the way native iOS/Android Settings apps
work: the account card stays pinned at the top of a single top-level
list, and every other section (2-9 above) becomes one tappable
`NooSettingsRow` - icon, title, chevron - in a `NooGroupedList` below it.
Tapping a row pushes a new screen (`NooTopBar`/`NooTopBarBack`) holding
just that section's own content full-screen, so no page is ever more
than one category deep and no section needs to be individually
collapsible any more. Two earlier designs were tried and dropped: a
trailing jump rail (one small icon per section, pinned where the
scrollbar would sit), and - after that - one long column of every
section inline, each individually collapsible (`NooGroupedList`'s
`collapsible` param) so the page could at least be collapsed down. Both
scrolled the *same* page to or past an anchor; a genuinely separate
pushed screen per category removes the scroll-depth problem outright
instead of just working around it. Desktop is unchanged: a 2-column grid
of cards with a 1px line and radius 20, wide enough to see most sections
without scrolling, so it gets neither a menu nor collapsing.
- **Share sheet / dialog:** sections in this order:
1. Header: file tile, name, size · folder, and close.
2. **Share with people:** an input ("Name, email or group"), then the people with access. The owner comes first; the others each have a permission pill ("Can edit ▾").
+263
View File
@@ -798,6 +798,269 @@ plugin's own manifest via merge, but kept explicit here too).
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
Flutter's own default to already be high enough.
**The three locks are independent** (Settings -> Security;
`SessionController`): `loginLockEnabled` (unlock to open the app - the only one
`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to
switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden
files). Each gate works with the others off - `passGate(gate, reason)`
prompts whenever its own `gate` is on, whether or not login lock is. Turning
any of them on **or off** needs a successful `AppLockService.authenticate`
(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set;
off needs it so someone with a momentarily unlocked phone can't remove it),
and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle
dependency is gone. Tests replace the prompt through
`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`.
`isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels
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.
## Native services on iOS (in progress)
The five `dev.ayushya.noo/*` channels (`share_intent`, `pick_intent`,
`upload_service`, `download_service`, `sync_service`, plus their status
`EventChannel`s) were written for Android in Kotlin. iOS implements
`upload_service` (+ its status stream), `download_service` and
`share_intent` and `sync_service` so far (`ios/Runner/Native/` +
`ios/ShareExtension/`, see below); for `pick_intent` there's no handler, so
calls throw `MissingPluginException`. `lib/services/native_channel.dart`
makes that safe until each one is built in Swift:
- `invokeIfAvailable` - a missing implementation returns null. For cold-start
checks (`getInitialShare`, `getPickRequest`, `getSyncStatus`) and
scheduling/cleanup (`reschedule`, `cancel`, `removeLocalSync`,
`finishPick`/`cancelPick`).
- `invokeOrExplain` - throws `NativeServiceUnavailable("<Feature>")`, whose
message callers already show in a snackbar ("Uploading isn't available on
this platform yet."). For things the user just asked for: `startUpload`,
`startDownload`, `syncNow`, `resolveConflict`.
- `quietEvents` - wraps an `EventChannel` so the missing-implementation error
is dropped instead of surfacing as an unhandled stream error.
When a channel gets a real iOS implementation nothing changes on the Dart
side - the helpers only act on `MissingPluginException`.
`SyncService.baseDirectory()` is where the `sync/<accountId>/...` mirror
lives: `getExternalStorageDirectory()` on Android (what `SyncEngine.kt`
writes), the app support directory elsewhere, because that call throws on
iOS. `OfflineController` and `localSyncedFilePath` both go through it.
### iOS upload/download (`ios/Runner/Native/`)
- `NativeServices.swift` registers the channels from
`AppDelegate.didInitializeImplicitFlutterEngine` (via
`engineBridge.applicationRegistrar.messenger()`) and forwards
`startUpload`/`startDownload` to `TransferManager`; a batch's completion
goes back over `upload_service/status` so `FilesController` refreshes the
folder like on Android.
- `TransferManager.swift` runs everything on one *background* `URLSession`
(`dev.ayushya.noo.transfers`, `sessionSendsLaunchEvents`), so transfers
finish after the app is suspended/closed; `AppDelegate` hands the system's
`handleEventsForBackgroundURLSession` completion handler to it and calls
`reconnect()` at launch. Each task carries its info in `taskDescription`
and its batch's running totals live in `UserDefaults`, so the one summary
local notification ("Uploaded 3 files") still fires after a relaunch.
- Uploads: one `PUT` per file to `<server>/remote.php/dav/files/<user>/...`
from a staged copy in Caches (no chunking, so very large files are bound by
the server's single-request limit; an existing file with the same name is
overwritten). Sends `X-OC-Mtime`. Downloads: `GET` into
`Documents/Downloads`, with `UIFileSharingEnabled` +
`LSSupportsOpeningDocumentsInPlace` set so it shows in the Files app under
"On My iPhone > Noo"; a name clash becomes `a (1).txt`.
- Notifications (`ios/Shared/TransferNotifications.swift`): "Uploading 3
files..." when a batch starts, replaced in place (same request id =
`transfer.<batch>`) by "Uploaded 3 files" when it ends. iOS can't show live
byte progress from a background session the way Android's foreground
service does (a Live Activity would, but needs a widget extension), and
there's no cancel yet. The Dart snackbar text still says "see the
notification for progress".
- `WebDAV.swift` (URL building) and `LocalFiles.uniqueURL` are pure and
covered by `ios/RunnerTests` (`xcodebuild test -workspace
ios/Runner.xcworkspace -scheme Runner -destination 'platform=iOS
Simulator,id=<udid>'`).
### iOS Share Extension (`ios/ShareExtension/`, `ios/Shared/`)
"Share to Noo" from any app's share sheet. A Share Extension can't open its
host app (`extensionContext.open` is Today-widget-only), so - like
Reminders/Notes - the destination is picked *inside* the sheet:
1. `ShareViewController` copies each shared file (`NSItemProvider.
loadFileRepresentation`, public.item) into the App Group container
(`group.dev.ayushya.noo`, `SharedInbox/<batch>/`) and hosts the SwiftUI
`SharePickerView` (`ShareModel` holds its state).
2. It reads the accounts from the shared Keychain (`SharedAccountStore` ->
`SharedAccounts`) and lists folders over WebDAV (`DavClient` `PROPFIND`
Depth 1, parsed by `DavFolderParser`). With **several accounts an account
list comes first** (the app's active one is marked "Active"; "Change
account" returns to it); with one it goes straight to its folders. The
Dart side keeps the keychain data current: `ShareAccountSync`
(`lib/services/share_account_service.dart`, started from the shell's
`initState`) republishes - through the native `share_account` channel's
`setAccounts` - when an account becomes ready or when its change
signature moves: the account list/active account, the three lock
settings, or the active account's hidden-files filter / storage scope;
sign-out calls
`clearAccount`. It publishes every saved account that has a stored
password and isn't signed out, each with its **own Files hidden-files
filter** (`FilesController.savedHiddenFilter`).
- **Toggles**: the folder list has two menu chips, **Hidden** (hide / only
hidden / all) and **Storage** (cloud only / only external / all +
external), each starting at the chosen account's own app setting
(`hiddenFilter`, `storageScope` published per account) and changeable for
this share only - nothing is written back. The list is fetched once per
folder (`DavClient.listFolders` returns everything, hidden and external
included, with `nc:mount-type` requested) and filtered in memory
(`ShareModel.visibleFolders`), so flipping a toggle just returns to the
top. Hidden uses the app's rule (`HiddenFilter`): a folder is hidden when
it *or any ancestor* starts with a dot. External storage: only a mount's
*root* has `mount-type=external`, so `ShareModel` remembers the roots it
has seen and treats anything under one as external too (the app's own
filter doesn't, which empties its list inside a mount). "All" lists
external folders in their own "External storage" group.
- **Hidden unlock**: turning hidden folders *on* (from `hide`), or opening
the sheet with the account's filter already not `hide`, asks for
Face ID/passcode when "lock hidden files" is on (`DeviceAuth`, the
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
like `local_auth` with `biometricOnly: false`); cancelling keeps them
hidden, with a note. Switching `only` <-> `all`, or back to `hide`, never
asks.
- **Account switching**: choosing any account other than the app's
active one asks for the same unlock when "lock account switching" is on.
**Every gated action prompts every time**, exactly like the app - no
unlock is remembered for the sheet (an earlier version cached one, so
later account picks / hidden toggles skipped Face ID). The only
merging is within one action: picking another account whose own filter
already shows hidden folders is a single prompt covering both
(`SharedAccounts.unlockNeeds` / `SelectionUnlock`, unit-tested).
Declining the account unlock cancels the pick; declining only the
hidden unlock shows the account with hidden folders hidden.
- Opening the sheet itself is not gated - only these two actions are
(as in the app, where login lock guards launch and these are separate
locks).
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
`URLSession` (`dev.ayushya.noo.transfers.share`, with
`sharedContainerIdentifier`) that outlives the extension, and posts
"Uploading N files...". Results go to whichever process is alive:
files that finish while the sheet is still open are counted by the
extension's `ShareUploadDelegate`; the rest by the app, which recreates
that session at launch (`TransferManager.reconnect`) and is relaunched by
the system. Either way `UploadResults.handle` deletes the staged copy,
counts the file in `TransferBatchStore` (batch totals live in the App
Group's `UserDefaults` because the two are different processes) and
posts the "Uploaded N files" summary on the last one; an upload the
extension finished leaves a note (`noteFinishedUpload`) so the app
refreshes that folder on its next activation. **Don't give the
extension's session no delegate** - a fast upload then finishes unseen and
the invalidated session is discarded before the app can ever hear of it
(found the hard way: no notification, no count).
4. Fallbacks: with no account (or the user taps "Choose a folder later in
Noo") the files go into `SharedInbox/pending.json` instead and a local
notification asks the user to open Noo; the app consumes that through
`share_intent` (`getInitialShare` at cold start, `share_intent/new` on
`UIApplication.didBecomeActiveNotification`) into the same Dart
`ShareUploadView` flow as an Android share, and `TransferManager.
startUpload` deletes the inbox copy once staged.
The keychain group is `$(AppIdentifierPrefix)dev.ayushya.noo.shared`
(`keychain-access-groups` in both `.entitlements`); both Info.plists also
carry it as `NooKeychainAccessGroup`, which `SharedAccountStore` reads, so the
two processes always agree on the team-prefixed id. Everything is stored as
JSON in one generic-password item (`kSecAttrAccessibleAfterFirstUnlock`).
`ios/Shared/` (compiled into both targets): `SharedInbox`, `SharedAccount`,
`ShareUpload`, `DavFolders`, `TransferTypes`, `WebDAV`. Everything in it that
isn't UI is unit-tested in `RunnerTests`. The extension's bundle id is
`dev.ayushya.noo.ShareExtension` and its version/build come from the same
`FLUTTER_BUILD_NAME`/`FLUTTER_BUILD_NUMBER` xcconfig as the app (iOS rejects a
mismatch); it's embedded by an "Embed Foundation Extensions" phase placed
*before* Flutter's script phases (after them Xcode reports a dependency
cycle). The simulator honours the App Group and keychain group without a
signing team; a real device needs a team that owns both ids.
### iOS device sync (`ios/Runner/Native/SyncEngine/`)
A Swift port of Android's `SyncEngine.kt`/`SyncWorker.kt`/
`ConflictResolveWorker.kt`/`SyncStatusBus.kt`, behind the same `sync_service`
channel (+ `/status` event stream) so the Dart side is unchanged apart from
`cancelAccount`'s `forget` flag (below). Same rules: only paths the user
synced are mirrored; brand-new local files are never uploaded (only edits to
already-synced files are); a file edited on both sides is a conflict (neither
copy is touched, "Keep local"/"Use server" resolves it); a local deletion is
repaired by re-downloading; the mirror lives at
`<Application Support>/sync/<accountId>/...` (what Dart's
`SyncService.baseDirectory()` reads for the Offline tab, excluded from
backup) with state in `<...>/sync-state/<accountId>/*.json`.
- `SyncDiff` (pure decisions, mirrors `diffFolder`), `DavSyncClient`
(PROPFIND/GET/PUT on a cookie-less ephemeral session), `SyncRunner` (one
account's pass), `SyncStore`, `SyncConfigStore` (Keychain), `SyncCoordinator`
(runs, `BGTaskScheduler`, notifications), and the `registerSync` channel
handlers in `NativeServices.swift`.
- **Safeguards (each has a regression test in `SyncEngineTests`, which drives
the real runner against an in-memory fake Nextcloud):** an unreachable
server, a non-207, or a PROPFIND answer that is truncated, not a
`multistatus`, empty, or has an href outside the account root / with dot
components throws `SyncRemoteUnavailable` and the path is skipped - never
diffed as empty or partial (which would delete local files); unreadable
recorded state makes the run do nothing (`SyncStateUnreadable`) instead of
treating every file as untracked and overwriting local edits; every
server-supplied path is resolved through `LocalFS.resolve` (no escaping the
mirror) and entries that would land on the same local file (case or Unicode
spellings) are skipped, not downloaded over each other; downloads go to a
temp file and replace atomically, never replacing a folder; empty-folder
pruning uses `rmdir` and only on a successful empty read; a root's etag
shortcut marker is only written when the path synced cleanly *and* the state
was saved.
- **Server deletions:** remove the local copy even if edited locally, matching
Android.
- **Deliberate differences from Android:** after our own upload the file's
etag is re-read so it isn't downloaded back; state is flushed every 20 transfers; conflicts are kept
per account on the status bus; downloads write to a temp file first. No
ongoing progress notification (a run posts a summary, and conflict
notifications with Keep local / Use server actions).
- **Running:** every run - "Sync now", the in-app refresh, and background -
goes through `SyncCoordinator.startRun`: one tracked run per account
(`force` supersedes it, a quiet check leaves it alone), serialised by one
cancellable `AsyncMutex`, tagged with a per-account *generation* so a run
that was only queued never starts for an account that was cancelled or had a
path removed meanwhile. `removeLocalSync` cancels the run and takes the same
mutex, so a finishing run can't write stale state back over it.
- **Background:** `BGAppRefreshTask` + `BGProcessingTask`
(`dev.ayushya.noo.sync.refresh`/`.processing`, `UIBackgroundModes` fetch +
processing, `BGTaskSchedulerPermittedIdentifiers` in Info.plist; handlers
registered before launch finishes). `intervalMinutes` (floored at 15, like
Android) is only the *earliest* start - iOS picks when. The Files cache
settings explain this on iOS; the 15-minute Android warning appears only
on Android for intervals below 15 minutes. Wi-Fi-only is
checked with `NWPathMonitor` (`isExpensive`) since a BGTask can't require
it. An expiring task cancels its runs (they save state as they go) and is
completed exactly once (`OnceGate`). **Not testable in the simulator**
(needs a real device and the Xcode `_simulateLaunchForTaskWithIdentifier`
debugger command).
- **Simulator verification:** a temporary debug control ran the native
background slot and confirmed files update for a non-active saved account.
The control and its channel handler were removed after verification.
`startBackgroundSlot` retains a regression test for periodic eligibility.
OS wake-up delivery and background execution time limits remain unverified.
- **Credentials:** `SyncConfigStore` keeps each account's server, auth header,
paths, interval and notify flag as one Keychain item
(`AfterFirstUnlock`, so a background run works with the phone locked).
Conflict notification actions look the account up there - nothing secret is
in a notification. `sync_service.cancel` takes `forget` (default true:
signed out/removed -> credentials dropped; false: only background sync was
turned off -> kept for manual runs and conflict actions). Dart sends
`forget: false` only for "paths configured but no background interval".
- **Gotcha:** `ios/.gitignore` has `**/*sync/`, which (case-insensitively on
macOS) swallows any directory named `Sync` - hence `SyncEngine/`.
Not built on iOS yet: the picker (File Provider) - see the iOS handoff notes.
+28 -29
View File
@@ -90,32 +90,6 @@ class/method already makes obvious.
`Align`).
- 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
Never use `flutter install` to push a build to a test device — it always
@@ -139,9 +113,11 @@ in this order: a local `android/key.properties` (gitignored — points at a
gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
the `.aab` Play Console wants), then falls back to the debug key if neither
build.yml`, triggered by `RC-Android-*` tags and producing a sideloadable
APK, and `.gitea/workflows/release.yml`, triggered by `Release-*` tags and
producing the `.aab` Play Console wants; `.gitea/workflows/ios.yml` builds
and uploads the signed iOS app to TestFlight on `RC-iOS-*` and `Release-*`
tags, on a self-hosted macOS runner), then falls back to the debug key if neither
is configured. As long as the same dedicated release keystore backs both
`key.properties` locally and the Gitea secrets, local release builds and
CI-built release APKs share one signature, so `adb install -r` works
@@ -157,3 +133,26 @@ Networking is deliberately split: `package:http` for simple JSON/XML
request-response calls, `package:dio` only where streaming/progress is
needed (downloads). Don't introduce a third HTTP client — extend the
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/`.
+206 -14
View File
@@ -29,11 +29,20 @@ widgets. Key points:
whole app in `main.dart`. When available and `useDynamicColor` is on, the
OS-provided `ColorScheme` wins over the seed color — always thread both
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
`NooColors.fromDynamicScheme` keeps `bg` on the OS `surface` but picks
`surface` (cards, selection bar, rows) from the container roles so it
stays lighter than `bg`: `surfaceContainerLowest` (white) in light,
`surfaceContainer` in dark. The dynamic_color package seeds the container
roles from the primary color rather than the OS palette, and in light mode
`surfaceContainerLow` is slightly *darker* than the OS `surface`, which
made cards blend into the page ("the selection bar has no background").
- **Font**: Inter via `google_fonts`, applied through
`GoogleFonts.interTextTheme(...)`. In tests, set
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
indefinitely (see `standards.md`).
`GoogleFonts.interTextTheme(...)`. Font files ship in
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
`main()` sets `GoogleFonts.config.allowRuntimeFetching = false`, so the
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,
`surfaceContainerLow`.
- **App bars**: flat, not centered, `surface` background.
@@ -77,11 +86,11 @@ Catalog:
| Folder | Components |
|---|---|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard`, `NooSelectionBar` |
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `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` |
| `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` |
Gotchas:
@@ -90,12 +99,29 @@ Gotchas:
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
do). Its `collapsible`/`initiallyExpanded` params (off by default) make
`label` a tap target that shows/hides the card - `SettingsSection` is the
only caller that opts in, for Settings' mobile sections.
- `NooSwipeAction` only reveals its action. The user has to tap the block to
trigger it; a full swipe never deletes.
`label` a tap target that shows/hides the card - no current caller opts
in (Settings' mobile sections used to, when every section rendered
inline in one long column; now each section is its own pushed screen -
see `account_view.dart`'s doc comment - so there's nothing left to
collapse). The params stay on the component itself since it's otherwise
generic.
- `NooSwipeAction` reveals its action on a normal swipe (tap the block to
trigger it) but also fires it directly if the drag goes far enough (past
1.8x the block's width) before release - a full swipe-through does delete
in one gesture, it's not tap-only anymore.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
built yet. `NooSidebar.windowControls` is the slot for it.
- Swipe settings offer every `SelectionActionKind` plus None. `SwipeAction`
persists the existing enum names and maps to that shared action catalog;
`FilesView` uses the single-item action-bar handlers, with the existing
delete confirmation retained. Labels/icons update for favorited and synced
items. Swipes remain disabled during selection, picking, and offline browsing.
- Settings → Action bar shows the real `NooSelectionBar` above its reorder
list. It updates immediately and supports the same More sheet; preview
actions only show feedback, never operate on files. The bar scales down
inside Settings' narrower column while keeping the mobile/tablet split.
- The Security setting is labelled **App lock** on every platform; its
subtitle still explains device PIN or biometric authentication.
- Always read colors through `context.nooColors`, never
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
@@ -108,7 +134,8 @@ Gotchas:
toggle, Shares' scope switcher), but pass `onSurface: true` for one
placed inside a sheet or dialog (already `colors.surface`), or its pill
track blends invisibly into the sheet instead of reading as a grouped
control (Files'/Photos' sort and type-filter sheets do this).
control (Files'/Photos' filter sheets and Settings controls on surface
rows use the same `surface2` track).
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
on its scrollable child, or pull-to-refresh silently can't be triggered
once the list is short enough to fit the viewport (empty, or one item) -
@@ -127,6 +154,32 @@ blocks are noted where they matter:
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
view-mode row shared by Files and Offline (`showStorageScope: false` for
Offline), and reused as-is by Favorites.
- Selection action bars in Files, Photos, and Favorites have a global
Settings → Action bar → Position choice: Top (default) or Bottom,
persisted as `ui_selection_bar_position`. Top stays pinned in the content
header; Bottom uses `SelectionBarOverlay` above navigation (or the content
pane's bottom on desktop), with extra list clearance. The overlay uses
Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation
and safe areas are accounted for without counting their height twice.
The shell hides Android's Upload FAB during selection via view callbacks.
The mobile Action bar settings use one grouped card for Position and
the reorder list, with no nested rounded corners or repeated page title.
Their header backdrop is transparent, so content scrolls behind the inset
`surface` card. A `line` border and `nooDialogShadow` match the floating
bottom bar's solid treatment.
- Hidden files and external storage are each a three-way segmented row
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
in both the Files and Photos filter sheets), not toggles:
`HiddenFilesFilter` hide (default) / only / include, and `StorageScope`
cloud (default) / external / all. Files and Photos each persist their own
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
`StorageScope` stays shared. Photos uses the same segmented pill for
Favorites (All / Favorites only). Leaving `hide` is still behind the
hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
regular items first, then a collapsible "External storage" section
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
holding the external ones; Photos/Favorites just merge them into one list.
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
trail Files uses in place of the shared
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
@@ -137,6 +190,12 @@ blocks are noted where they matter:
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
`tab_location.dart`) — the loading/error/empty-state slivers and
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
`tab_state_slivers.dart` also has `topBarSliver`, which every regular
tab's view uses to plant its own `AppTopBar` instance as the first sliver
in its `CustomScrollView` (a thin wrapper around the framework's
`SliverFloatingHeader`) instead of the shell passing one shared instance
to `Scaffold.appBar` - see its doc comment and `architecture.md`'s
"Mobile" bullet for why.
`lib/widgets/settings/` — Settings' 8 section widgets plus
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
`settings_dialogs.dart`'s `confirmRemoveAccount`.
@@ -184,6 +243,27 @@ blocks are noted where they matter:
bars and video transport controls (its other former user, the floating
bottom nav bar, is gone - see below). Reuse this for any new floating
overlay instead of building a new blur/shadow combo.
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
expanded always labelled. Phone/Android layout only.
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
out past `viewport.height / doc.height`, so for short documents (<= 3
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
the boundary height reaches the viewport and fit-width stays reachable
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
+ top bar height, 0 when the controls are hidden) animates the viewport
below the overlaid top bar.
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
under the label, above the card; `SettingsSection` uses it for its
`notice` on phones.
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
the media viewer's bottom bar as a draggable panel (mobile/phone only;
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
Details button, or swiping up on swipeable media (raw `Listener` in
`file_viewer_screen.dart`) grows it upward so the action row rides on top
of the details; tapping the media collapses it.
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
@@ -191,7 +271,93 @@ blocks are noted where they matter:
opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
instead of a corner badge.
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
don't confuse the two: this one is still non-blurred, just inset with a
`line` border and `nooDialogShadow` (also used by floating selection bars - see
that constant's doc comment) instead of edge to edge (no opacity/blur knob
either). `floating` ignores `NooNavStyle` and always uses the Android row
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
only the attached bar still has a distinct iOS row. A separate, orthogonal
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
"Frosted glass bottom bar", off by default, both platforms and both bar
styles) swaps the bar's solid `surface` for a translucent one over a
`BackdropFilter` blur and drops
`nooDialogShadow` (it would show through the glass). Strength is user-set:
`FrostedGlassPreset` Less (blur 10 / opacity 0.88), Default (20 / 0.72, the
old fixed values) and More (32 / 0.55), plus Blur (0-40) and Opacity
(0.3-1.0) sliders (`NooSlider`, `core/`) in Settings -> Appearance, shown
only while frosted glass is on; both persist
(`ui_bottom_bar_frosted_blur`/`_opacity`) and reach the bar as
`NooBottomBar.frostedBlur`/`frostedOpacity`. No preset is highlighted once
the sliders are moved off all three. A frosted bar only
reads as glass with content behind it, so the shell uses
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
footprint for attached+frosted too, not just floating. The host `Scaffold` needs `extendBody: true` while it's active
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
which also means every tab's own scrollable list has to reserve enough
bottom padding to clear the bar - nothing does that automatically once the
body draws behind it. Use `bottomBarClearance(context)`
(`tab_state_slivers.dart`) for that rather than a flat `100`; it already
accounts for both bar styles.
Settings' separate "Search in bottom bar" toggle
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search
entry to either bar style (`NooBottomBar`'s `searchDestination`/
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
anywhere that needs the *current* cap on regular tabs.
- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
a persisted Avatar position choice (`ui_avatar_position`, Top by default).
`AvatarNavigationHost` owns one 420ms open / 320ms close animation using
`easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
its controls fade in after the expansion starts. Top moves the content pane
down through `AvatarNavigationBody`, keeping bottom navigation stationary.
Bottom moves the avatar upward into the card's bottom-right corner and
overlays the list immediately above navigation. Both use the Files gutter.
The floating rail beside a bottom avatar shows text only on the selected
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
with the avatar inside the trailing end. Its popup expands upward with
full screen width, rounded top corners and no gap above the bar; the
avatar stays in the bar, and Search keeps its inline position.
The attached popup has no outer border or shadow, blending into the bar.
All bottom popups order navigation (including Settings), current account,
then expanded secondary accounts and Add/Manage Account controls.
The iOS shell places Upload and the top avatar on the large-title row.
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
shows the plus-and-Upload button. Upload uses the folder icon's tonal
`accentSoft` background and `accentText` foreground. Search stays below.
Upload uses the compact 32px size to match the visible top avatar.
On iOS the accent picker hides the unsupported wallpaper color option;
the accent subtitle, swatch and selection always reflect the seed color.
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
A shared shell animation keeps separately mounted tab headers in sync
during Auto tab changes; reduced motion skips the transition.
Avatar navigation and its bottom satellite share the bottom bar's frost
toggle, blur and opacity, including throughout the popup animation.
The setting is labelled "Frosted glass" and also controls both media
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
without a backdrop filter; On uses the shared blur and tint opacity.
Search starts inside the rail when a bottom avatar occupies the satellite;
its row slot smoothly collapses as Search fades into that satellite while
the avatar moves into the popup. Closing reverses the same path. The card
retains hidden tabs, Settings, account switching, Add Account, and Manage
Accounts. Account controls scroll on short screens. Tap outside, tap the
popup avatar, or system Back to close. Reduced motion opens/closes without
the transition. Drawer gestures are disabled in avatar mode. Standalone
top bars outside the shell retain `showGeneralDialog` as a fallback.
`ShellAvatarButton` keeps its vertical-swipe account shortcuts in either
position. Its onTap override must have a matching tooltip/semantics label.
Also added
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
second search row under the way iOS's `search:` slot does, so an inline
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
search isn't in the bottom bar) sits to the title's own right in that
flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/
ellipsizes if there's truly no room but doesn't claim more than it needs
otherwise - instead of the title being replaced by it.
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including
@@ -214,11 +380,13 @@ blocks are noted where they matter:
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
for the video player's seek bar, plus a perpetually-animated
for the non-iOS video player's seek bar, plus a perpetually-animated
`SeekBarPreview` wrapper used by the Settings style picker so every
preview always matches the real widget exactly (same painter, just fed
demo `progress`/`phase` values). Add new seek-bar presets here, not by
forking the painter.
iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style
picker. Scrubbing pauses playback temporarily and restores its prior state.
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
back button + filename, the bottom action bar, the video transport
controls) all share one small hand-rolled icon-button pattern
@@ -254,3 +422,27 @@ blocks are noted where they matter:
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
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; every text file opens read-only (plain text as-is, Markdown rendered) and follows Edit -> Save: an Edit/Preview toggle and, once dirty, a Save button live in the viewer's top bar as round icon-only `NooButton`s (`iconOnly`, 36px; a lone button is primary, with two Edit/Preview is secondary and Save primary; Save becomes a primary circle with a spinner while it saves). Offline copies are read-only with no buttons. The preview owns the text, the editing mode and the save; a `TextPreviewController` (owned by `FileViewerScreen`, passed to `MediaTextPreview`) mirrors that state out so the screen's top bar can draw - and trigger - the buttons, which keeps the preview's widget tree unchanged. `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's.
`showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with the same Edit/Preview toggle in the viewer's top bar (hidden for read-only offline copies). Other text files go straight to the editor.
`ShareSheet`: focusing the people search field does not scroll; once the user types, `_revealPeopleSection` animates the "Share with people" section (keyed by `_peopleKey` on the `NooShareSection`, not the inner column) to just below the sheet's top edge with a small gap.
## 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
+31
View File
@@ -0,0 +1,31 @@
# CI-only: switches the Release configuration of the Runner and ShareExtension
# targets to manual signing with the App Store profiles. The committed project
# stays on Automatic signing so local development is unaffected; this edit
# happens only inside the CI checkout. Run from the repo root:
# ruby .gitea/scripts/ios_ci_signing.rb
require "xcodeproj"
TEAM = "Q3JLTAG9PV"
# Pinned by SHA-1, not by the "Apple Distribution" name: a runner that also
# holds other same-named distribution certs (e.g. a dev Mac's login keychain)
# would otherwise let Xcode pick one the profiles don't include. Update this
# (and ios/ExportOptions.plist) when the certificate is renewed.
CERT_SHA1 = "2527806871D806E8E27221B15CA8A1938CF216F2"
PROFILES = {
"Runner" => "NooProfile",
"ShareExtension" => "NooShareSheetProfile",
}.freeze
project = Xcodeproj::Project.open("ios/Runner.xcodeproj")
PROFILES.each do |target_name, profile|
target = project.targets.find { |t| t.name == target_name } or abort("no target #{target_name}")
target.build_configurations.select { |c| c.name == "Release" }.each do |config|
settings = config.build_settings
settings["CODE_SIGN_STYLE"] = "Manual"
settings["DEVELOPMENT_TEAM"] = TEAM
settings["CODE_SIGN_IDENTITY"] = CERT_SHA1
settings["CODE_SIGN_IDENTITY[sdk=iphoneos*]"] = CERT_SHA1
settings["PROVISIONING_PROFILE_SPECIFIER"] = profile
end
end
project.save
+6 -5
View File
@@ -1,12 +1,13 @@
name: Build APK
# Deliberately not "on every push" - only when a release-candidate tag
# (RC1, RC2, RC2026.1, ...) is pushed. Tag the commit you want built:
# git tag RC1 && git push origin RC1
# Deliberately not "on every push" - only when an Android release-candidate
# tag (RC-Android-1, RC-Android-2, ...) is pushed. Tag the commit you want built:
# git tag RC-Android-1 && git push origin RC-Android-1
# iOS has its own RC-iOS-* tags (ios.yml); Release-* builds both platforms.
on:
push:
tags:
- "RC*"
- "RC-Android-*"
jobs:
build:
@@ -72,7 +73,7 @@ jobs:
# talks to this same Gitea instance automatically via the
# `github.token`/`github.server_url` context Gitea Actions
# provides for compatibility. Release is named/tagged after
# whatever tag triggered this run (e.g. "RC1").
# whatever tag triggered this run (e.g. "RC-Android-1").
uses: akkuman/gitea-release-action@v1
with:
files: ${{ env.APK_PATH }}
+103
View File
@@ -0,0 +1,103 @@
name: Build iOS
# Tag scheme (shared with the Android workflows):
# Release-<version> -> release.yml (Play bundle) AND this workflow
# RC-Android-<n> -> build.yml (APK) only
# RC-iOS-<n> -> this workflow only
# git tag RC-iOS-1 && git push origin RC-iOS-1
# Builds a signed .ipa and uploads it to TestFlight. Needs a Mac runner
# registered with the `macos` label (gitea-runner in host mode) with Xcode
# installed; Linux runners cannot build iOS.
on:
push:
tags:
- "Release-*"
- "RC-iOS-*"
jobs:
build:
runs-on: macos
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Check tag matches pubspec version
# Only Release-* tags carry a version; RC-iOS-* tags are free-form.
if: startsWith(github.ref_name, 'Release-')
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
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Install signing certificate and profiles
# A throwaway keychain keeps the distribution key off the runner's
# login keychain; it is deleted again in the cleanup step.
env:
CERT_P12_BASE64: ${{ secrets.IOS_DIST_CERT_P12_BASE64 }}
CERT_PASSWORD: ${{ secrets.IOS_DIST_CERT_PASSWORD }}
APP_PROFILE_BASE64: ${{ secrets.IOS_APP_PROFILE_BASE64 }}
EXT_PROFILE_BASE64: ${{ secrets.IOS_EXT_PROFILE_BASE64 }}
run: |
KEYCHAIN="${{ runner.temp }}/ci-signing.keychain-db"
KEYCHAIN_PASSWORD=$(uuidgen)
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
echo "$CERT_P12_BASE64" | base64 -d > "${{ runner.temp }}/dist.p12"
security import "${{ runner.temp }}/dist.p12" -P "$CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
rm -f "${{ runner.temp }}/dist.p12"
# Must list 2527806871D8... (the cert the profiles embed); if it
# doesn't, IOS_DIST_CERT_P12_BASE64 holds the wrong certificate.
security find-identity -v -p codesigning "$KEYCHAIN"
PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILES_DIR"
echo "$APP_PROFILE_BASE64" | base64 -d > "$PROFILES_DIR/NooProfile.mobileprovision"
echo "$EXT_PROFILE_BASE64" | base64 -d > "$PROFILES_DIR/NooShareSheetProfile.mobileprovision"
- name: Switch Release config to manual signing
run: ruby .gitea/scripts/ios_ci_signing.rb
- name: Build signed IPA
run: flutter build ipa --release --export-options-plist=ios/ExportOptions.plist
- name: Rename IPA to Noo-<version>.ipa
run: |
VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //' | cut -d'+' -f1)
mkdir -p release
cp build/ios/ipa/*.ipa "release/Noo-${VERSION}.ipa"
echo "IPA_PATH=release/Noo-${VERSION}.ipa" >> "$GITHUB_ENV"
- name: Upload to TestFlight
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
run: |
mkdir -p "$HOME/.appstoreconnect/private_keys"
echo "$ASC_KEY_P8_BASE64" | base64 -d > "$HOME/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8"
xcrun altool --upload-app -f "$IPA_PATH" -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
- name: Cleanup signing material
if: always()
run: |
security delete-keychain "${{ runner.temp }}/ci-signing.keychain-db" || true
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/NooProfile.mobileprovision" \
"$HOME/Library/MobileDevice/Provisioning Profiles/NooShareSheetProfile.mobileprovision"
rm -rf "$HOME/.appstoreconnect/private_keys"
+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
+27 -25
View File
@@ -3,7 +3,7 @@ name: Build App Bundle
# Deliberately not "on every push" - only when a real release tag (Release-1.0.0,
# Release-1.2.3, ...) is pushed. Tag the commit you want built:
# git tag Release-1.0.0 && git push origin Release-1.0.0
# Unlike the RC* flow (build.yml), this builds the .aab Play Console wants,
# Unlike the RC-Android-* flow (build.yml), this builds the .aab Play Console wants,
# not a sideloadable .apk.
on:
push:
@@ -39,6 +39,16 @@ jobs:
# platform/build-tools versions it actually needs on its own.
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
run: flutter pub get
@@ -46,7 +56,7 @@ jobs:
run: flutter analyze
- name: Decode release keystore
# Same key as the RC* APK builds, so a Play Store upload's signature
# Same key as the RC-Android-* APK builds, so a Play Store upload's signature
# matches anything sideloaded from a release build. See
# android/app/build.gradle.kts.
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
@@ -67,6 +77,7 @@ jobs:
mkdir -p release
cp build/app/outputs/bundle/release/app-release.aab "release/Noo-${VERSION}.aab"
echo "AAB_PATH=release/Noo-${VERSION}.aab" >> "$GITHUB_ENV"
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
- name: Publish Gitea release with App Bundle attached
# Gitea-native release action (not GitHub's) - authenticates and
@@ -78,26 +89,17 @@ jobs:
with:
files: ${{ env.AAB_PATH }}
- name: Decode Play Console service account key
# Skipped (rather than failing the whole run) until the secret is
# set up - see tool/upload_play_bundle.py's doc comment for what
# that setup involves and its one hard prerequisite: Google won't
# accept API calls for an app that's never had a single manual
# upload through the Play Console web UI.
if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }}
run: echo "$SERVICE_ACCOUNT_JSON_BASE64" | base64 -d > "${{ runner.temp }}/play-service-account.json"
env:
SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
- name: Upload App Bundle to Play Console library
# Deliberately doesn't touch any release track - see
# tool/upload_play_bundle.py. Uploaded bundles show up under
# Release > App bundle explorer in Play Console, ready to promote
# to a track by hand whenever it's actually time to ship.
if: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 != '' }}
run: |
pip install --quiet google-api-python-client google-auth
python3 tool/upload_play_bundle.py "${{ env.AAB_PATH }}"
env:
GOOGLE_PLAY_PACKAGE_NAME: dev.ayushya.noo
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: ${{ runner.temp }}/play-service-account.json
- 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/release
# Play Store screenshot tooling (tool/screenshots.sh)
/store_listing/screenshots/
/tool/demo_server/.venv/
# Local Claude Code state
.claude/worktrees/
.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.
+87 -32
View File
@@ -1,36 +1,46 @@
# Noo
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
and Material You.
and Material 3.
Noo gives you a clean, modern way to browse and manage the files on your own
Nextcloud server — files, photos, shares, activity, and trash, all in one
app, themed to match your device.
Noo is a clean, modern way to browse and manage the files on your own
Nextcloud server: files, photos, shares, activity, and trash in one app,
themed to match your device and laid out for both phones and tablets.
Website: <https://noo.ayushya.dev>
> Noo is an independent, unofficial client. It is not affiliated with or
> endorsed by Nextcloud GmbH.
## Features
- **Files** — browse, upload, move, rename, delete, and favorite files and
folders; sort and filter per folder
- **Photos** — a dedicated media gallery with a full-screen viewer for images
and video
- **Activity** — a feed of what's changed on your server
- **Shares** — see and manage what you've shared and what's shared with you
- **Trash** — restore or permanently delete recently removed items
- **Material You theming** — dynamic color on supported devices, five
built-in accent colors, light/dark/AMOLED
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign
in through your browser, and Noo never sees or stores your password,
only a scoped app token
## Screenshots
_Coming soon._
- **Files**: browse, upload, move, copy, rename, delete, and favorite files
and folders; sort and filter per folder; list or grid view
- **Photos**: a media gallery with a full-screen viewer for images and video
- **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
pull-up details panel (info, activity, sharing)
- **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
have shared, what changed, and what you deleted
- **Search** across your server
- **Offline and sync**: keep files available offline and sync chosen folders
in the background
- **Multiple accounts**: switch between saved accounts from the avatar menu
- **App lock**: optionally require device authentication to open the app,
switch accounts, or reveal hidden files
- **Share to Noo**: upload from any app's share sheet
- **Make it yours**: Material You dynamic color, five accent colors,
light/dark/AMOLED, attached or floating bottom bar, hamburger or avatar
navigation, adjustable upload button, and a tablet layout with a sidebar
- **Secure sign-in**: authenticates through Nextcloud's Login Flow v2. You
sign in through your browser, and Noo never sees or stores your password,
only a scoped app password
## Getting started
Prerequisites:
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
- An Android device or emulator, an iOS device, or Windows
- A Nextcloud server with Login Flow v2 enabled (on by default since
Nextcloud 15)
@@ -39,38 +49,83 @@ flutter pub get
flutter run
```
On first launch, enter your server's address — Noo opens your browser to
On first launch, enter your server's address. Noo opens your browser to
finish signing in.
## Supported platforms
Android, iOS, and Windows.
## Building
```bash
flutter build apk # Android
flutter build apk # Android (debug-signed unless you configure a key)
flutter build ios # iOS
flutter build windows # Windows
```
Release builds read signing details from `android/key.properties` or from
`RELEASE_KEYSTORE_*` environment variables (see `android/app/build.gradle.kts`).
Neither the keystore nor `key.properties` is, or should ever be, committed.
Without them, release builds fall back to debug signing.
## Tech stack
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV
and OCS APIs — no bundled Nextcloud SDK.
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
transfers and background sync.
## Contributing
Project context for contributors (human or AI) lives in
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server
integration, styling conventions, and code standards.
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
first. In short:
```bash
flutter test # run tests
flutter analyze # static analysis / lints
```
Architecture, server integration, styling conventions, and code standards are
documented in [`CLAUDE.md`](CLAUDE.md) and `.claude/context/`, written for
both human and AI contributors.
## Privacy
Noo talks only to the Nextcloud server you point it at. It has no analytics
and no third-party tracking. Read the full [privacy policy](https://noo.ayushya.dev/privacy).
## Acknowledgements
Typefaces [Instrument Sans](https://fonts.google.com/specimen/Instrument+Sans)
and [Schibsted Grotesk](https://fonts.google.com/specimen/Schibsted+Grotesk)
are bundled under the SIL Open Font License, and icons are from
[Lucide](https://lucide.dev). Those keep their own licenses; the license
below covers Noo's own code and assets.
## License
Not yet licensed for redistribution — license TBD.
Noo is source-available under the
[PolyForm Shield License 1.0.0](LICENSE). In plain terms: you may read, build,
run, and modify the code, and contribute changes back, but you may not use it
to make a product or service that competes with Noo. Please do not republish
it as your own app.
Contributions are welcome under the terms in
[`CONTRIBUTING.md`](CONTRIBUTING.md).
### Why PolyForm Shield?
I want the code to be open to read, learn from, build, and improve, while
keeping anyone from lifting it and shipping a competing copy of the app.
Permissive licenses (MIT, Apache) allow exactly that, and copyleft ones
(GPL) still allow a rebranded fork. PolyForm Shield sits in between: it is
not an OSI "open source" license, and I'm not claiming it is, but it keeps
the code available while protecting the project.
## Where development happens
The source of truth is a self-hosted Gitea instance, and this GitHub
repository is a read-only mirror plus the public issue tracker. I keep it
that way so that I own my hosting, builds, and release credentials rather
than depending on a third party for them. You don't have to use Gitea:
open issues and pull requests here as usual. Open PRs are imported to my
instance, reviewed and merged there, and `main` is mirrored back here, so a
merged PR may show as closed with a link to the commit instead of "Merged".
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details.
+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.
+87
View File
@@ -0,0 +1,87 @@
# Noo store listing assets
English artwork for the App Store and Google Play, built from Noo's
production Flutter widgets with fictional data (example.com servers, made-up
people and files). No real accounts, credentials or network requests are
involved. The five screenshots follow the Features section of the website
(`noo.ayushya.dev`), in the same order:
1. Multiple accounts: the account switcher
2. Locks: the lock screen with the Security card over it
3. Offline sync: the Offline tab with the File sync card over it
4. Sharing: the Share sheet (people, groups, public link)
5. External storage: Files with the mounted-storage section
The landscape tablet sets show the app's real tablet layout (sidebar and
toolbar), captured from the actual shell widget. Portrait and landscape tablet
screenshots go in the same store slot; upload whichever orientation you want
to show, or both.
Look: the app's own design tokens (warm neutrals, one violet accent,
Schibsted Grotesk / Instrument Sans), flat shapes, alternating light and dark.
## Upload files
| Store slot | Location | Dimensions |
| --- | --- | --- |
| Google Play app icon | `android/icon-512.png` | 512 × 512 |
| Google Play feature graphic | `android/feature-graphic-1024x500.png` | 1024 × 500 |
| Google Play phone screenshots | `android/phone/01-*.png` … `05-*.png` | 1080 × 1920 |
| Google Play 7-inch tablet screenshots | `android/tablet-7in/` | 1200 × 1920 |
| Google Play 10-inch tablet screenshots | `android/tablet-10in/` | 1600 × 2560 |
| App Store iPhone screenshots (6.9" / 6.7" slot) | `ios/iphone/` | 1290 × 2796 |
| App Store iPad screenshots (13" slot) | `ios/ipad/` | 2064 × 2752 |
| Google Play 7-inch tablet, landscape | `android/tablet-7in-landscape/` | 1920 × 1200 |
| Google Play 10-inch tablet, landscape | `android/tablet-10in-landscape/` | 2560 × 1600 |
| App Store iPad screenshots, landscape (13" slot) | `ios/ipad-landscape/` | 2752 × 2064 |
| App Store iPhone 6.1" / 6.3" screenshots | `ios/iphone-6.3in/` | 1206 × 2622 |
| App Store iPhone Duo (foldable), portrait | `ios/iphone-fold-portrait/` | 2007 × 2853 |
| App Store iPhone Duo (foldable), landscape | `ios/iphone-fold-landscape/` | 2853 × 2007 |
| App Store header | `ios/header/` | 5244 × 2950, 3840 × 1646 |
| App Store search results | `ios/search-results/` | 5244 × 2950, 3840 × 2560, 1920 × 1280 |
| iOS icon reference | `ios/icon-1024.png` | 1024 × 1024 |
The iPad 13" slot (2064 × 2752 and 2752 × 2064) is covered by `ios/ipad/` and
`ios/ipad-landscape/`. The header and search-results banners are one brand
design (name, tagline, iPad and iPhone) rendered at each size. App previews
(video) are not produced here.
Upload in numeric order. The App Store uses the icon embedded in the uploaded
build; `ios/icon-1024.png` is only a reference copy. Screenshots, the feature
graphic and the iOS icon are opaque RGB PNGs; the Play icon is 32-bit RGBA.
`manifest.json` records each file's exact size and mode. `index.html` and
`contact-sheet.jpg` are review materials, not store uploads.
`store-listing-assets.zip` holds the upload files, this guide, the manifest
and the draft listing text.
`listing-copy.json` has draft text for both stores, written around the same
five features. The App Store name is `NooCloud` (the plain name was taken);
the home-screen name stays `Noo` via `CFBundleDisplayName`. Character limits
are checked by `tools/compose.py`.
Dimensions checked against the official requirements on October 7, 2026:
- [Google Play preview assets](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en)
- [Apple screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications)
## Reproduce and edit
The app needs a current stable Flutter (Dart ^3.12). From the repository root:
```sh
flutter test --no-pub distribution/store-assets/tools/capture_test.dart
python3 -m venv /tmp/noo-store-assets-venv
/tmp/noo-store-assets-venv/bin/pip install Pillow
/tmp/noo-store-assets-venv/bin/python distribution/store-assets/tools/compose.py
```
`capture_test.dart` lives outside `test/` so normal test runs never rewrite
marketing assets. It renders the real widgets (account switcher, lock screen,
Security and File sync sections, Offline tab, Share sheet, Files with
external storage) at phone and tablet sizes into `source/`, using demo
subclasses of the session, files, sync and item-operation controllers.
`compose.py` lays headlines, colors and device panels around those captures;
change copy, colors or output sizes there.
Review the output before uploading, and re-capture after UI changes.
These files are prepared locally; no store listing has been changed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

@@ -0,0 +1,15 @@
{
"locale": "en-US",
"google_play": {
"title": "Noo - Nextcloud",
"short_description": "The Nextcloud app that just works: accounts, locks, offline sync, sharing.",
"description": "Noo is a simple, dependable client for your own Nextcloud server. You host it; Noo makes it effortless for everyone who uses it.\n\nMULTIPLE ACCOUNTS\nKeep your personal server, your work instance and the family box signed in at once and switch in one tap. Each account keeps its own sync scope, cache and favorites.\n\nLOCKS WHERE YOU WANT THEM\nProtect opening the app, switching accounts and revealing hidden files with your device's own PIN, pattern or biometric. Noo never stores or sees your PIN.\n\nOFFLINE AND BACKGROUND SYNC\nMirror a few folders, or the whole account, to your device and keep reading and previewing with no connection. Background sync tops them up, on Wi-Fi only unless you say otherwise, and tells you when something conflicts.\n\nSHARING THAT FEELS LIKE NEXTCLOUD\nInvite people or groups with a permission pill, switch a public link on and copy it, or send the file itself straight to another app.\n\nEXTERNAL STORAGE, BUILT IN\nFolders your admin mounts through Nextcloud's External storage app (SMB, S3, SFTP and more) appear in Files like any other folder.\n\nMAKE IT YOURS\nPin your favorite tabs, order the action bar, choose swipe actions, and adjust the theme and navigation around how you use your cloud.\n\nPRIVATE BY DESIGN\nNoo never asks for your password: sign-in uses your server's own login page (Login Flow v2) and a revocable app token. The app talks only to your server. No analytics, no trackers, no ads.\n\nRequires a Nextcloud server. Noo is an independent, unofficial client; Nextcloud is a trademark of Nextcloud GmbH."
},
"app_store": {
"name": "NooCloud",
"subtitle": "The Nextcloud app that works",
"promotional_text": "Multiple accounts, app locks, offline sync, sharing and external storage for your own Nextcloud. No analytics, no trackers.",
"keywords": "nextcloud,files,photos,offline,sync,self-hosted,storage,cloud,share,webdav",
"description": "Noo is a simple, dependable client for your own Nextcloud server. You host it; Noo makes it effortless for everyone who uses it, on iPhone and iPad.\n\nMULTIPLE ACCOUNTS\nKeep your personal server, your work instance and the family box signed in at once and switch in one tap. Each account keeps its own sync scope, cache and favorites.\n\nLOCKS WHERE YOU WANT THEM\nProtect opening the app, switching accounts and revealing hidden files with Face ID, Touch ID or your device passcode. Noo never stores or sees your passcode.\n\nOFFLINE AND BACKGROUND SYNC\nMirror a few folders, or the whole account, to your device and keep opening and previewing files with no connection. iOS decides when background sync can run, so timing is opportunistic rather than on a fixed schedule. Noo tells you when something conflicts.\n\nSHARING THAT FEELS LIKE NEXTCLOUD\nInvite people or groups with a permission pill, switch a public link on and copy it, or send the file itself straight to another app.\n\nEXTERNAL STORAGE, BUILT IN\nFolders your admin mounts through Nextcloud's External storage app (SMB, S3, SFTP and more) appear in Files like any other folder.\n\nMAKE IT YOURS\nPin your favorite tabs, order the action bar, choose swipe actions, and adjust the theme and navigation around how you use your cloud.\n\nPRIVATE BY DESIGN\nNoo never asks for your password: sign-in uses your server's own login page (Login Flow v2) and a revocable app token. The app talks only to your server. No analytics, no trackers, no ads.\n\nRequires a Nextcloud server. Noo is an independent, unofficial client; Nextcloud is a trademark of Nextcloud GmbH."
}
}
+566
View File
@@ -0,0 +1,566 @@
[
{
"file": "android/phone/01-accounts.png",
"size": [
1080,
1920
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "android/phone/02-locks.png",
"size": [
1080,
1920
],
"mode": "RGB",
"story": "locks"
},
{
"file": "android/phone/03-sync.png",
"size": [
1080,
1920
],
"mode": "RGB",
"story": "sync"
},
{
"file": "android/phone/04-sharing.png",
"size": [
1080,
1920
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "android/phone/05-external.png",
"size": [
1080,
1920
],
"mode": "RGB",
"story": "external"
},
{
"file": "android/tablet-7in/01-accounts.png",
"size": [
1200,
1920
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "android/tablet-7in/02-locks.png",
"size": [
1200,
1920
],
"mode": "RGB",
"story": "locks"
},
{
"file": "android/tablet-7in/03-sync.png",
"size": [
1200,
1920
],
"mode": "RGB",
"story": "sync"
},
{
"file": "android/tablet-7in/04-sharing.png",
"size": [
1200,
1920
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "android/tablet-7in/05-external.png",
"size": [
1200,
1920
],
"mode": "RGB",
"story": "external"
},
{
"file": "android/tablet-10in/01-accounts.png",
"size": [
1600,
2560
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "android/tablet-10in/02-locks.png",
"size": [
1600,
2560
],
"mode": "RGB",
"story": "locks"
},
{
"file": "android/tablet-10in/03-sync.png",
"size": [
1600,
2560
],
"mode": "RGB",
"story": "sync"
},
{
"file": "android/tablet-10in/04-sharing.png",
"size": [
1600,
2560
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "android/tablet-10in/05-external.png",
"size": [
1600,
2560
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/iphone/01-accounts.png",
"size": [
1290,
2796
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "ios/iphone/02-locks.png",
"size": [
1290,
2796
],
"mode": "RGB",
"story": "locks"
},
{
"file": "ios/iphone/03-sync.png",
"size": [
1290,
2796
],
"mode": "RGB",
"story": "sync"
},
{
"file": "ios/iphone/04-sharing.png",
"size": [
1290,
2796
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "ios/iphone/05-external.png",
"size": [
1290,
2796
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/ipad/01-accounts.png",
"size": [
2064,
2752
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "ios/ipad/02-locks.png",
"size": [
2064,
2752
],
"mode": "RGB",
"story": "locks"
},
{
"file": "ios/ipad/03-sync.png",
"size": [
2064,
2752
],
"mode": "RGB",
"story": "sync"
},
{
"file": "ios/ipad/04-sharing.png",
"size": [
2064,
2752
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "ios/ipad/05-external.png",
"size": [
2064,
2752
],
"mode": "RGB",
"story": "external"
},
{
"file": "android/tablet-7in-landscape/01-accounts.png",
"size": [
1920,
1200
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "android/tablet-7in-landscape/02-locks.png",
"size": [
1920,
1200
],
"mode": "RGB",
"story": "locks"
},
{
"file": "android/tablet-7in-landscape/03-sync.png",
"size": [
1920,
1200
],
"mode": "RGB",
"story": "sync"
},
{
"file": "android/tablet-7in-landscape/04-sharing.png",
"size": [
1920,
1200
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "android/tablet-7in-landscape/05-external.png",
"size": [
1920,
1200
],
"mode": "RGB",
"story": "external"
},
{
"file": "android/tablet-10in-landscape/01-accounts.png",
"size": [
2560,
1600
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "android/tablet-10in-landscape/02-locks.png",
"size": [
2560,
1600
],
"mode": "RGB",
"story": "locks"
},
{
"file": "android/tablet-10in-landscape/03-sync.png",
"size": [
2560,
1600
],
"mode": "RGB",
"story": "sync"
},
{
"file": "android/tablet-10in-landscape/04-sharing.png",
"size": [
2560,
1600
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "android/tablet-10in-landscape/05-external.png",
"size": [
2560,
1600
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/ipad-landscape/01-accounts.png",
"size": [
2752,
2064
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "ios/ipad-landscape/02-locks.png",
"size": [
2752,
2064
],
"mode": "RGB",
"story": "locks"
},
{
"file": "ios/ipad-landscape/03-sync.png",
"size": [
2752,
2064
],
"mode": "RGB",
"story": "sync"
},
{
"file": "ios/ipad-landscape/04-sharing.png",
"size": [
2752,
2064
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "ios/ipad-landscape/05-external.png",
"size": [
2752,
2064
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/iphone-6.3in/01-accounts.png",
"size": [
1206,
2622
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "ios/iphone-6.3in/02-locks.png",
"size": [
1206,
2622
],
"mode": "RGB",
"story": "locks"
},
{
"file": "ios/iphone-6.3in/03-sync.png",
"size": [
1206,
2622
],
"mode": "RGB",
"story": "sync"
},
{
"file": "ios/iphone-6.3in/04-sharing.png",
"size": [
1206,
2622
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "ios/iphone-6.3in/05-external.png",
"size": [
1206,
2622
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/iphone-fold-portrait/01-accounts.png",
"size": [
2007,
2853
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "ios/iphone-fold-portrait/02-locks.png",
"size": [
2007,
2853
],
"mode": "RGB",
"story": "locks"
},
{
"file": "ios/iphone-fold-portrait/03-sync.png",
"size": [
2007,
2853
],
"mode": "RGB",
"story": "sync"
},
{
"file": "ios/iphone-fold-portrait/04-sharing.png",
"size": [
2007,
2853
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "ios/iphone-fold-portrait/05-external.png",
"size": [
2007,
2853
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/iphone-fold-landscape/01-accounts.png",
"size": [
2853,
2007
],
"mode": "RGB",
"story": "accounts"
},
{
"file": "ios/iphone-fold-landscape/02-locks.png",
"size": [
2853,
2007
],
"mode": "RGB",
"story": "locks"
},
{
"file": "ios/iphone-fold-landscape/03-sync.png",
"size": [
2853,
2007
],
"mode": "RGB",
"story": "sync"
},
{
"file": "ios/iphone-fold-landscape/04-sharing.png",
"size": [
2853,
2007
],
"mode": "RGB",
"story": "sharing"
},
{
"file": "ios/iphone-fold-landscape/05-external.png",
"size": [
2853,
2007
],
"mode": "RGB",
"story": "external"
},
{
"file": "ios/header/header-5244x2950.png",
"size": [
5244,
2950
],
"mode": "RGB",
"story": "banner"
},
{
"file": "ios/header/header-3840x1646.png",
"size": [
3840,
1646
],
"mode": "RGB",
"story": "banner"
},
{
"file": "ios/search-results/search-5244x2950.png",
"size": [
5244,
2950
],
"mode": "RGB",
"story": "banner"
},
{
"file": "ios/search-results/search-3840x2560.png",
"size": [
3840,
2560
],
"mode": "RGB",
"story": "banner"
},
{
"file": "ios/search-results/search-1920x1280.png",
"size": [
1920,
1280
],
"mode": "RGB",
"story": "banner"
},
{
"file": "android/icon-512.png",
"size": [
512,
512
],
"mode": "RGBA"
},
{
"file": "ios/icon-1024.png",
"size": [
1024,
1024
],
"mode": "RGB"
},
{
"file": "android/feature-graphic-1024x500.png",
"size": [
1024,
500
],
"mode": "RGB"
}
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Some files were not shown because too many files have changed in this diff Show More