26 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
205 changed files with 11670 additions and 837 deletions
+17 -9
View File
@@ -57,15 +57,16 @@ new provider instance):
saved-accounts list, which one is active, `sessionGeneration` — see saved-accounts list, which one is active, `sessionGeneration` — see
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`, below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
`loginFlowStatus`), the active `NextcloudService` instance, and login lock `loginFlowStatus`), the active `NextcloudService` instance, and login lock
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/ (`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/ independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling `addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
controllers — constructed after `SessionController` and unable to hold a controllers — constructed after `SessionController` and unable to hold a
forward reference to it — can react to login/logout/account-switch forward reference to it — can react to login/logout/account-switch
without a circular dependency. without a circular dependency.
- [`SettingsController`](../../lib/providers/settings_controller.dart) — - [`SettingsController`](../../lib/providers/settings_controller.dart) —
global UI prefs independent of login state: theme mode/seed color/dynamic 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`/ order/visibility/default (`requestedTab`/`requestTab`/
`consumeRequestedTab`), swipe actions. `consumeRequestedTab`), swipe actions.
- [`FilesController`](../../lib/providers/files_controller.dart) — the - [`FilesController`](../../lib/providers/files_controller.dart) — the
@@ -282,11 +283,13 @@ the sidebar):
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar` - **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
for *every* tab (previously only Files had shell-level top chrome, with for *every* tab (previously only Files had shell-level top chrome, with
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
large title, an inline search field, a `plus` action on Files only (no large title with Upload and the top account avatar on the same row,
other tab has a create/upload flow), and the account avatar; Android and an inline search field below; Android
gets a compact title row with `search`/avatar actions, relying on an gets a compact title row with `search`/avatar actions, relying on an
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar extended `NooFab` instead of a top-bar upload button. Both use the same
icon for upload. Rather than one shared instance in `Scaffold.appBar`, 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 `MainShellView` builds a separate `AppTopBar` per tab (labelled for that
tab) and each tab plants its own as the first sliver in its own tab) and each tab plants its own as the first sliver in its own
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`, `CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
@@ -312,8 +315,13 @@ the sidebar):
section yet). `SettingsController.navMenuStyle` (Settings → Appearance → section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
"Navigation menu") offers an alternative to the hamburger/drawer pair: "Navigation menu") offers an alternative to the hamburger/drawer pair:
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`) the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
instead, a dropdown holding the same hidden-tabs + Settings content - instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
see `styling.md`'s Gotchas for the wiring. 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, - **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
remaining tabs, storage meter, Settings) sits beside a `NooToolbar` remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
(tab title, search, an "Upload" action on Files/Photos) over the same (tab title, search, an "Upload" action on Files/Photos) over the same
@@ -168,10 +168,10 @@ Use **Lucide** (pinned to `lucide-static@0.460.0`) at a 1.8 stroke. Sizes are 14
- Shared: `users` in fg-3. - Shared: `users` in fg-3.
**Swipe action** **Swipe action**
- The row slides and uncovers a 96px action block. Delete is white on danger-fill; Favorite is white on accent. - The row slides and uncovers a 96px action block. Delete is white on danger-fill; all other actions are white on accent.
- The block has a 20px icon above a 12/600 label. - The block has a 20px icon above a 12/600 label.
- Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it. - Releasing past half the block snaps it open; dragging further still (past 1.8× the block's width, a haptic tick marks the crossing and the icon bumps up 15%) arms the action, and releasing while armed fires it immediately - one swipe-through gesture instead of open-then-tap. The block grows to fill however far it's dragged, so there's never a gap behind it.
- Swipe actions are set in Settings → Swipe on a file. - Swipe actions are set in Settings → Swipe on a file. Both directions offer None and every action-bar action: Favorite, Share, Download, Delete, Copy, Move, Rename, Sync to device, Details. They use the same single-item handlers; Delete still asks for confirmation.
**Selection action bar** (Files, Photos, Favorites - anywhere with **Selection action bar** (Files, Photos, Favorites - anywhere with
multi-select) multi-select)
@@ -289,7 +289,7 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
| Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached | | Upload | `plus` in the nav bar | Extended FAB - fully round (pill) when the bottom bar is floating, rounded square (`card` radius) when it's attached |
| Overflow icon | `ellipsis` | `ellipsis-vertical` | | Overflow icon | `ellipsis` | `ellipsis-vertical` |
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. | | Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" | | App lock label | "App lock" | "App lock" |
### Desktop ### Desktop
@@ -366,7 +366,9 @@ 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, priority order for the Selection action bar's bulk actions (favorite,
share, download, delete, copy, move, rename, sync, details) - the share, download, delete, copy, move, rename, sync, details) - the
first few (3 mobile / 4 desktop) land in the bar's fixed inline first few (3 mobile / 4 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 9. Swipe on a file
Mobile is a two-level menu, the way native iOS/Android Settings apps Mobile is a two-level menu, the way native iOS/Android Settings apps
+253
View File
@@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too).
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
Flutter's own default to already be high enough. 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 `isRestoringSession` still gates the splash screen until the above resolves
— see `standards.md` for why widget tests must mock both storage channels — see `standards.md` for why widget tests must mock both storage channels
rather than relying on this async path throwing naturally. rather than relying on this async path throwing naturally.
@@ -811,3 +824,243 @@ rather than relying on this async path throwing naturally.
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share 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 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. 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.
+5 -3
View File
@@ -113,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 gitignored keystore file, e.g. `android/app/release-keystore.jks`), then
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/ CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/ `_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and build.yml`, triggered by `RC-Android-*` tags and producing a sideloadable
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing APK, and `.gitea/workflows/release.yml`, triggered by `Release-*` tags and
the `.aab` Play Console wants), then falls back to the debug key if neither 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 is configured. As long as the same dedicated release keystore backs both
`key.properties` locally and the Gitea secrets, local release builds and `key.properties` locally and the Gitea secrets, local release builds and
CI-built release APKs share one signature, so `adb install -r` works CI-built release APKs share one signature, so `adb install -r` works
+89 -48
View File
@@ -86,7 +86,7 @@ Catalog:
| Folder | Components | | Folder | Components |
|---|---| |---|---|
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` | | `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` | | `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` | | `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` | | `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
@@ -111,6 +111,17 @@ Gotchas:
in one gesture, it's not tap-only anymore. in one gesture, it's not tap-only anymore.
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't - Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
built yet. `NooSidebar.windowControls` is the slot for it. built yet. `NooSidebar.windowControls` is the slot for it.
- 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 - Always read colors through `context.nooColors`, never
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3 `Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
scheme, reseeded by the user's accent color choice (`AppTheme.light`/ scheme, reseeded by the user's accent color choice (`AppTheme.light`/
@@ -123,7 +134,8 @@ Gotchas:
toggle, Shares' scope switcher), but pass `onSurface: true` for one toggle, Shares' scope switcher), but pass `onSurface: true` for one
placed inside a sheet or dialog (already `colors.surface`), or its pill placed inside a sheet or dialog (already `colors.surface`), or its pill
track blends invisibly into the sheet instead of reading as a grouped 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()` - Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
on its scrollable child, or pull-to-refresh silently can't be triggered 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) - once the list is short enough to fit the viewport (empty, or one item) -
@@ -142,6 +154,19 @@ blocks are noted where they matter:
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/ `NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
view-mode row shared by Files and Offline (`showStorageScope: false` for view-mode row shared by Files and Offline (`showStorageScope: false` for
Offline), and reused as-is by Favorites. 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 - Hidden files and external storage are each a three-way segmented row
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`, (`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
in both the Files and Photos filter sheets), not toggles: in both the Files and Photos filter sheets), not toggles:
@@ -149,7 +174,8 @@ blocks are noted where they matter:
cloud (default) / external / all. Files and Photos each persist their own cloud (default) / external / all. Files and Photos each persist their own
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
`ui_show_hidden*` bools are still read as a fallback, `true` → include); `ui_show_hidden*` bools are still read as a fallback, `true` → include);
`StorageScope` stays shared. Leaving `hide` is still behind the `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: hidden-files lock gate. With `StorageScope.all`, Files splits the listing:
regular items first, then a collapsible "External storage" section regular items first, then a collapsible "External storage" section
(`_ExternalStorageHeader`, local `_externalExpanded` state, default open) (`_ExternalStorageHeader`, local `_externalExpanded` state, default open)
@@ -248,16 +274,23 @@ blocks are noted where they matter:
instead of a corner badge. `NooBottomBar` later gained its own, unrelated instead of a corner badge. `NooBottomBar` later gained its own, unrelated
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") - `NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
don't confuse the two: this one is still non-blurred, just inset with a don't confuse the two: this one is still non-blurred, just inset with a
`line` border and `nooDialogShadow` (the app's one other shadow user - see `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 that constant's doc comment) instead of edge to edge (no opacity/blur knob
either). `floating` ignores `NooNavStyle` and always uses the Android row either). `floating` ignores `NooNavStyle` and always uses the Android row
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there; (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 only the attached bar still has a distinct iOS row. A separate, orthogonal
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance → `SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
"Frosted glass bottom bar", off by default, both platforms and both bar "Frosted glass bottom bar", off by default, both platforms and both bar
styles) swaps the bar's solid `surface` for a 0.72-alpha one over a styles) swaps the bar's solid `surface` for a translucent one over a
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops `BackdropFilter` blur and drops
`nooDialogShadow` (it would show through the glass). A frosted bar only `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 reads as glass with content behind it, so the shell uses
`NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and `NooBottomBar.drawsBehindBody(barStyle, frosted)` for `extendBody`, and
`bottomBarClearance` (and Files' own trailing sliver) reserve the bar's `bottomBarClearance` (and Files' own trailing sliver) reserve the bar's
@@ -274,44 +307,50 @@ blocks are noted where they matter:
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one - `onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`, use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
anywhere that needs the *current* cap on regular tabs. anywhere that needs the *current* cap on regular tabs.
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`, - `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
Settings → Appearance → "Navigation menu") picks what opens hidden tabs + navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or a persisted Avatar position choice (`ui_avatar_position`, Top by default).
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart) `AvatarNavigationHost` owns one 420ms open / 320ms close animation using
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu` `easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and its controls fade in after the expansion starts. Top moves the content pane
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) - down through `AvatarNavigationBody`, keeping bottom navigation stationary.
a caller passing a custom `onTap` *must* also pass a matching `label`, or Bottom moves the avatar upward into the card's bottom-right corner and
the tooltip/semantics still say "Accounts" for a button that no longer overlays the list immediately above navigation. Both use the Files gutter.
opens the account switcher. In `avatarMenu` mode `main.dart` also sets The floating rail beside a bottom avatar shows text only on the selected
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
the drawer. The menu header has a chevron that expands an account section with the avatar inside the trailing end. Its popup expands upward with
(other saved accounts to switch to, "Add Account", "Manage Accounts") full screen width, rounded top corners and no gap above the bar; the
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of avatar stays in the bar, and Search keeps its inline position.
`showGeneralDialog` directly (`barrierColor: Colors.transparent` + The attached popup has no outer border or shadow, blending into the bar.
`barrierDismissible: true` for a non-dimming click-outside-to-close menu, All bottom popups order navigation (including Settings), current account,
not a modal flow) - there's no existing anchored-popup primitive here then expanded secondary accounts and Add/Manage Account controls.
(`PopupMenuButton`'s own width doesn't stretch to a full content column), The iOS shell places Upload and the top avatar on the large-title row.
so don't reach for `showNooSheet`/`showNooDialog` for something shaped Upload uses the shared `ui_fab_style` preference: Auto expands on Files
like this. It's positioned just past the status bar (`SafeArea`'s own and Photos and collapses elsewhere; Mini is icon-only; Expanded always
inset, not the top bar's full height on top of that) so it covers the shows the plus-and-Upload button. Upload uses the folder icon's tonal
top bar - including the tab title - rather than sitting below it, and `accentSoft` background and `accentText` foreground. Search stays below.
its card carries two stacked `boxShadow`s rather than just Upload uses the compact 32px size to match the visible top avatar.
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough On iOS the accent picker hides the unsupported wallpaper color option;
to read as basically invisible on a small card over a dark theme's the accent subtitle, swatch and selection always reflect the seed color.
near-black `bg` (a dark, diffuse shadow needs real density close to the Upload's width, padding and label opacity interpolate over `NooMotion.base`.
edge to be visible against an already-dark backdrop), so a second, A shared shell animation keeps separately mounted tab headers in sync
tighter, more opaque contact shadow underneath it gives real elevation during Auto tab changes; reduced motion skips the transition.
in both themes. The card's border can go missing wherever an opaque row Avatar navigation and its bottom satellite share the bottom bar's frost
sits against it too (every corner but the header's, which has no toggle, blur and opacity, including throughout the popup animation.
full-bleed fill of its own) if a `Container` combines `border` with its The setting is labelled "Frosted glass" and also controls both media
own `clipBehavior` - that paints the border as part of the *outer* viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
decoration, then the clipped child on top right up to the same boundary, without a backdrop filter; On uses the shared blur and tint opacity.
with no gap for the border's own stroke to show through. Fixed the same Search starts inside the rail when a bottom avatar occupies the satellite;
way any bordered-and-clipped `Container` should be: no `clipBehavior` on its row slot smoothly collapses as Search fades into that satellite while
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius the avatar moves into the popup. Closing reverses the same path. The card
reduced by that same 1px) around the filled, clipped content instead, so retains hidden tabs, Settings, account switching, Add Account, and Manage
it never paints over the border. Also added 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 `NooTopBar.androidTitleTrailing`: Android has no large title to put a
second search row under the way iOS's `search:` slot does, so an inline second search row under the way iOS's `search:` slot does, so an inline
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
@@ -341,11 +380,13 @@ blocks are noted where they matter:
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere. `FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart) - [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly) — 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 `SeekBarPreview` wrapper used by the Settings style picker so every
preview always matches the real widget exactly (same painter, just fed preview always matches the real widget exactly (same painter, just fed
demo `progress`/`phase` values). Add new seek-bar presets here, not by demo `progress`/`phase` values). Add new seek-bar presets here, not by
forking the painter. 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 - Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
back button + filename, the bottom action bar, the video transport back button + filename, the bottom action bar, the video transport
controls) all share one small hand-rolled icon-button pattern controls) all share one small hand-rolled icon-button pattern
@@ -384,11 +425,11 @@ blocks are noted where they matter:
## Text/markdown viewer ## Text/markdown viewer
`MediaTextPreview` is an editable monospace `TextField` padded clear of the status bar, top bar and action bar; a Save button appears when dirty (read-only for offline copies). `NooPersonAccessRow.trailing` replaces the owner label/permission pill (used by share-search results). Sheets whose close button lives in `showNooSheet` children must pop via a `Builder` context, not the caller's. `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. `showNooSheet` insets its body by the keyboard (`viewInsets.bottom`) so focused fields stay visible; the body's widget structure must not change when the keyboard opens, or the sheet content is rebuilt and loses focus.
Markdown files (`.md`/`.markdown`) in `MediaTextPreview` open rendered via `flutter_markdown_plus` (`Markdown`, styled from Noo tokens in `_markdownStyle`), with a top-right Edit/Preview toggle (hidden for read-only offline copies). Other text files go straight to the editor. 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. `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.
+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 name: Build APK
# Deliberately not "on every push" - only when a release-candidate tag # Deliberately not "on every push" - only when an Android release-candidate
# (RC1, RC2, RC2026.1, ...) is pushed. Tag the commit you want built: # tag (RC-Android-1, RC-Android-2, ...) is pushed. Tag the commit you want built:
# git tag RC1 && git push origin RC1 # 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: on:
push: push:
tags: tags:
- "RC*" - "RC-Android-*"
jobs: jobs:
build: build:
@@ -72,7 +73,7 @@ jobs:
# talks to this same Gitea instance automatically via the # talks to this same Gitea instance automatically via the
# `github.token`/`github.server_url` context Gitea Actions # `github.token`/`github.server_url` context Gitea Actions
# provides for compatibility. Release is named/tagged after # 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 uses: akkuman/gitea-release-action@v1
with: with:
files: ${{ env.APK_PATH }} 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"
+2 -2
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, # 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: # 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 # 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. # not a sideloadable .apk.
on: on:
push: push:
@@ -56,7 +56,7 @@ jobs:
run: flutter analyze run: flutter analyze
- name: Decode release keystore - 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 # matches anything sideloaded from a release build. See
# android/app/build.gradle.kts. # android/app/build.gradle.kts.
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks" run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
+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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

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