Compare commits
@@ -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
|
||||||
@@ -275,16 +276,20 @@ bare system prompt gives no context on its own.
|
|||||||
|
|
||||||
`MainShellView` builds its chrome from the Noo nav kit
|
`MainShellView` builds its chrome from the Noo nav kit
|
||||||
(`widgets/noo/nav/`) and switches between two layouts on
|
(`widgets/noo/nav/`) and switches between two layouts on
|
||||||
`NooLayout.isDesktop`:
|
`NooLayout.isDesktop` (width >= 900dp *and* shortest side >= 600dp, so a
|
||||||
|
phone in landscape keeps the mobile layout and only tablet-class windows get
|
||||||
|
the sidebar):
|
||||||
|
|
||||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||||
for *every* tab (previously only Files had shell-level top chrome, with
|
for *every* tab (previously only Files had shell-level top chrome, with
|
||||||
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`,
|
||||||
@@ -310,8 +315,13 @@ bare system prompt gives no context on its own.
|
|||||||
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
|
||||||
|
|||||||
@@ -798,8 +798,269 @@ 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.
|
||||||
|
|
||||||
- `putBytes(path, bytes)` does a WebDAV `PUT` to overwrite a file; used by the text/markdown editor (`MediaTextPreview`).
|
- `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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -137,10 +139,20 @@ existing split instead.
|
|||||||
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
|
`.gitea/workflows/release.yml` (on `Release-*` tags) first checks the tag
|
||||||
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
|
equals `Release-<pubspec version>`, builds the signed `.aab`, attaches it to a
|
||||||
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
|
Gitea release, then uploads it with `r0adkll/upload-google-play` to the
|
||||||
`internal` track as a **draft** (promote in Play Console, or edit
|
`internal` track with `status: completed`, so it rolls out to internal
|
||||||
`track`/`status` in the workflow). Requires the `PLAY_SERVICE_ACCOUNT_JSON`
|
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
|
repo secret (service account with release permissions; the app's first
|
||||||
release must be uploaded manually). Play rejects a repeated `versionCode`, so
|
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
|
bump the `+N` in `pubspec.yaml` for every tag - re-tagging the same version
|
||||||
will fail the upload. Release notes come from
|
will fail the upload. Release notes come from
|
||||||
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
|
`distribution/whatsnew/whatsnew-en-US` (max 500 chars) - update per release.
|
||||||
|
|
||||||
|
## Community PR flow
|
||||||
|
|
||||||
|
Gitea is the source of truth; GitHub is a push mirror (never commit to GitHub
|
||||||
|
`main`) and the public issue tracker. `scripts/sync-github-prs.sh` (cron on the
|
||||||
|
home server) imports open GitHub PRs into Gitea via Agit
|
||||||
|
(`refs/for/main`, topic `gh-pr-N`), where `.gitea/workflows/pr.yml` runs
|
||||||
|
analyze + tests. That workflow must never use secrets; signing and Play
|
||||||
|
credentials stay on the tag-triggered `build.yml`/`release.yml`. Issue
|
||||||
|
templates are in `.github/ISSUE_TEMPLATE/`.
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ widgets. Key points:
|
|||||||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||||||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||||||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||||||
|
`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
|
- **Font**: Inter via `google_fonts`, applied through
|
||||||
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
`GoogleFonts.interTextTheme(...)`. Font files ship in
|
||||||
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
`assets/google_fonts/` (Instrument Sans, Schibsted Grotesk) and
|
||||||
@@ -79,11 +86,11 @@ 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`, `NooSummaryCard`, `NooSelectionBar` |
|
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
||||||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||||||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` |
|
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` (floating rounded card with margin; `NooToolbar.outerHeight` for `appBar` sizing) |
|
||||||
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
||||||
|
|
||||||
Gotchas:
|
Gotchas:
|
||||||
@@ -104,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`/
|
||||||
@@ -116,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) -
|
||||||
@@ -135,6 +154,32 @@ 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
|
||||||
|
(`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
|
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||||||
trail Files uses in place of the shared
|
trail Files uses in place of the shared
|
||||||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||||||
@@ -198,6 +243,27 @@ blocks are noted where they matter:
|
|||||||
bars and video transport controls (its other former user, the floating
|
bars and video transport controls (its other former user, the floating
|
||||||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||||||
overlay instead of building a new blur/shadow combo.
|
overlay instead of building a new blur/shadow combo.
|
||||||
|
- Upload FAB size: `SettingsController.fabStyle` (`FabStyle.auto/mini/expanded`,
|
||||||
|
Settings > Appearance > "Upload button") drives `NooFab.collapsed` in
|
||||||
|
`main.dart` - auto collapses off Files/Photos, mini always icon-only,
|
||||||
|
expanded always labelled. Phone/Android layout only.
|
||||||
|
- PDF viewer (`media_pdf_preview.dart`): pdfx's InteractiveViewer won't zoom
|
||||||
|
out past `viewport.height / doc.height`, so for short documents (<= 3
|
||||||
|
pages, measured up front) the full-size `PdfViewPinch` is wrapped in a
|
||||||
|
`MediaQuery` with extra bottom padding (pdfx's child is a `SafeArea`) so
|
||||||
|
the boundary height reaches the viewport and fit-width stays reachable
|
||||||
|
after pinch-zooming, without shrinking the canvas. `topInset` (status bar
|
||||||
|
+ top bar height, 0 when the controls are hidden) animates the viewport
|
||||||
|
below the overlaid top bar.
|
||||||
|
- `NooGroupedList.notice`: optional widget (e.g. `NooInfoNote`) rendered
|
||||||
|
under the label, above the card; `SettingsSection` uses it for its
|
||||||
|
`notice` on phones.
|
||||||
|
- [`MediaDetailsPanel`](../../lib/widgets/viewer/media_details_panel.dart) —
|
||||||
|
the media viewer's bottom bar as a draggable panel (mobile/phone only;
|
||||||
|
tablet-class still uses the `DetailsSheet` dialog). Dragging the bar, the
|
||||||
|
Details button, or swiping up on swipeable media (raw `Listener` in
|
||||||
|
`file_viewer_screen.dart`) grows it upward so the action row rides on top
|
||||||
|
of the details; tapping the media collapses it.
|
||||||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||||||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||||||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||||||
@@ -208,9 +274,27 @@ 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). The host `Scaffold` needs `extendBody: true` while it's active
|
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||||
|
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||||
|
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||||
|
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||||
|
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||||
|
styles) swaps the bar's solid `surface` for a 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`),
|
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||||||
which also means every tab's own scrollable list has to reserve enough
|
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
|
bottom padding to clear the bar - nothing does that automatically once the
|
||||||
@@ -223,40 +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. `showAvatarMenu` is this app's first use of
|
The floating rail beside a bottom avatar shows text only on the selected
|
||||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
|
||||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
with the avatar inside the trailing end. Its popup expands upward with
|
||||||
not a modal flow) - there's no existing anchored-popup primitive here
|
full screen width, rounded top corners and no gap above the bar; the
|
||||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
avatar stays in the bar, and Search keeps its inline position.
|
||||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
The attached popup has no outer border or shadow, blending into the bar.
|
||||||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
All bottom popups order navigation (including Settings), current account,
|
||||||
inset, not the top bar's full height on top of that) so it covers the
|
then expanded secondary accounts and Add/Manage Account controls.
|
||||||
top bar - including the tab title - rather than sitting below it, and
|
The iOS shell places Upload and the top avatar on the large-title row.
|
||||||
its card carries two stacked `boxShadow`s rather than just
|
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
|
||||||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
|
||||||
to read as basically invisible on a small card over a dark theme's
|
shows the plus-and-Upload button. Upload uses the folder icon's tonal
|
||||||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
`accentSoft` background and `accentText` foreground. Search stays below.
|
||||||
edge to be visible against an already-dark backdrop), so a second,
|
Upload uses the compact 32px size to match the visible top avatar.
|
||||||
tighter, more opaque contact shadow underneath it gives real elevation
|
On iOS the accent picker hides the unsupported wallpaper color option;
|
||||||
in both themes. The card's border can go missing wherever an opaque row
|
the accent subtitle, swatch and selection always reflect the seed color.
|
||||||
sits against it too (every corner but the header's, which has no
|
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
|
||||||
full-bleed fill of its own) if a `Container` combines `border` with its
|
A shared shell animation keeps separately mounted tab headers in sync
|
||||||
own `clipBehavior` - that paints the border as part of the *outer*
|
during Auto tab changes; reduced motion skips the transition.
|
||||||
decoration, then the clipped child on top right up to the same boundary,
|
Avatar navigation and its bottom satellite share the bottom bar's frost
|
||||||
with no gap for the border's own stroke to show through. Fixed the same
|
toggle, blur and opacity, including throughout the popup animation.
|
||||||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
The setting is labelled "Frosted glass" and also controls both media
|
||||||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
|
||||||
reduced by that same 1px) around the filled, clipped content instead, so
|
without a backdrop filter; On uses the shared blur and tint opacity.
|
||||||
it never paints over the border. Also added
|
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
|
`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
|
||||||
@@ -286,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
|
||||||
@@ -329,10 +425,24 @@ 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.
|
||||||
|
|
||||||
|
## Desktop/tablet layout notes
|
||||||
|
|
||||||
|
- The desktop branch in `main.dart` is wrapped in `SafeArea` so tablets keep
|
||||||
|
system bars clear of the sidebar; the sidebar has no divider between pinned
|
||||||
|
and hidden tabs.
|
||||||
|
- Desktop Settings is the same two-level menu as mobile (centered 640px column, back button in the floating toolbar); the
|
||||||
|
pushed category screens render the desktop card layout.
|
||||||
|
- Settings that don't apply in wide-tablet (Appearance nav options,
|
||||||
|
Swipe on a file) show a `NooInfoNote` via `SettingsSection.notice`.
|
||||||
|
- Mobile-layout grids (Files, Favorites, move/copy picker, share upload,
|
||||||
|
Photos) take their column count from `NooLayout.gridColumns` (min tile
|
||||||
|
width, never below the phone count), so a tablet in portrait gets more
|
||||||
|
columns rather than a few huge tiles.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -89,9 +89,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
files: ${{ env.AAB_PATH }}
|
files: ${{ env.AAB_PATH }}
|
||||||
|
|
||||||
- name: Upload to Google Play (internal track, draft)
|
- name: Upload to Google Play (internal track, auto-publish)
|
||||||
# Stages the build only; promote it in Play Console, or change
|
# Rolls the build out to internal testers automatically
|
||||||
# `status` to "completed" (and/or `track`) to roll out automatically.
|
# (`status: completed`); promote it to other tracks in Play Console.
|
||||||
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
|
# Needs the PLAY_SERVICE_ACCOUNT_JSON repo secret, and the app's
|
||||||
# first release must already have been uploaded by hand.
|
# first release must already have been uploaded by hand.
|
||||||
uses: r0adkll/upload-google-play@v1
|
uses: r0adkll/upload-google-play@v1
|
||||||
@@ -101,5 +101,5 @@ jobs:
|
|||||||
releaseFiles: ${{ env.AAB_PATH }}
|
releaseFiles: ${{ env.AAB_PATH }}
|
||||||
releaseName: ${{ env.VERSION }}
|
releaseName: ${{ env.VERSION }}
|
||||||
track: internal
|
track: internal
|
||||||
status: draft
|
status: completed
|
||||||
whatsNewDirectory: distribution/whatsnew
|
whatsNewDirectory: distribution/whatsnew
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Something isn't working as expected
|
||||||
|
labels: [bug]
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what
|
||||||
|
attributes:
|
||||||
|
label: What happened?
|
||||||
|
description: What you did, what you expected, and what happened instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Noo version
|
||||||
|
description: Settings → About
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: device
|
||||||
|
attributes:
|
||||||
|
label: Device and OS version
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: server
|
||||||
|
attributes:
|
||||||
|
label: Nextcloud server version
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Screenshots or logs
|
||||||
|
description: Remove any personal data or server addresses you don't want public.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security problem
|
||||||
|
url: https://github.com/ayushyamitabh/noo/blob/main/SECURITY.md
|
||||||
|
about: Please report security issues privately, not as public issues.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an improvement
|
||||||
|
labels: [enhancement]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: >
|
||||||
|
Noo aims to be a simple, just-works Nextcloud client with reasonable
|
||||||
|
customization, not a replacement for every Nextcloud feature. Requests
|
||||||
|
that fit that goal are most likely to be accepted.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: What problem are you trying to solve?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: idea
|
||||||
|
attributes:
|
||||||
|
label: What would you like to see?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What does this change, and why? Link the issue it closes, e.g. "Closes #123". -->
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## Screenshots / recordings
|
||||||
|
|
||||||
|
<!-- Required for UI changes: before/after, light + dark, phone + tablet width where relevant. Delete if not applicable. -->
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!-- How did you verify this? Device/emulator and OS version for anything platform-specific. -->
|
||||||
|
|
||||||
|
- [ ] `flutter analyze` is clean
|
||||||
|
- [ ] `flutter test` passes
|
||||||
|
- [ ] Tried it on a device or emulator (UI / platform changes)
|
||||||
|
- [ ] Tested against a real Nextcloud server (server / sync changes)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] New or changed behavior has tests where practical
|
||||||
|
- [ ] Docs in `.claude/context/` updated if architecture, server integration, styling, or standards changed
|
||||||
|
- [ ] No secrets, server URLs, credentials, or personal data in code, tests, or screenshots
|
||||||
|
- [ ] Commits follow the commit message template (`.gitmessage`)
|
||||||
@@ -45,3 +45,14 @@ app.*.map.json
|
|||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
|
||||||
|
# Local Claude Code state
|
||||||
|
.claude/worktrees/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/scheduled_tasks.lock
|
||||||
|
|
||||||
|
# Signing / secrets (never commit)
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
key.properties
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# <Summary: imperative, sentence case, no trailing period, <= 72 chars>
|
||||||
|
#
|
||||||
|
# Why is this change needed? What does it do? Wrap lines at 72 chars.
|
||||||
|
# Explain the "why"; the diff already shows the "what".
|
||||||
|
#
|
||||||
|
# Examples of good summaries:
|
||||||
|
# Fix PDF zoom not returning to fit-width
|
||||||
|
# Add Upload button size setting
|
||||||
|
# Bump to 1.0.2+5
|
||||||
|
#
|
||||||
|
# Closes #<issue>
|
||||||
|
#
|
||||||
|
# Trailers (keep the ones that apply):
|
||||||
|
# Co-Authored-By: Name <email>
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Lines starting with '#' are ignored. Enable this template once with:
|
||||||
|
# git config commit.template .gitmessage
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Contributing to Noo
|
||||||
|
|
||||||
|
Thanks for your interest in improving Noo. Bug reports, ideas, and pull
|
||||||
|
requests are welcome.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- For anything bigger than a small fix, open an issue first so we can agree on
|
||||||
|
the approach before you spend time on it.
|
||||||
|
- Read [`CLAUDE.md`](CLAUDE.md) and the topic files in
|
||||||
|
[`.claude/context/`](.claude/context): architecture, server integration,
|
||||||
|
styling, and code standards. They apply to human and AI contributors alike.
|
||||||
|
- Never include real server URLs, usernames, app passwords, or personal files
|
||||||
|
in issues, tests, or screenshots.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
You need a Nextcloud server to sign in to. A throwaway local instance is best
|
||||||
|
for testing anything destructive (delete, move, trash).
|
||||||
|
|
||||||
|
## Making a change
|
||||||
|
|
||||||
|
1. Branch from `main`.
|
||||||
|
2. Keep the change focused; unrelated cleanup belongs in its own PR.
|
||||||
|
3. Run the checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flutter analyze
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Format only the files you touched (`dart format <files>`), not the whole
|
||||||
|
tree.
|
||||||
|
5. If you changed architecture, server integration, styling conventions, or
|
||||||
|
standards, update the matching file in `.claude/context/` in the same PR.
|
||||||
|
6. Open a pull request and fill in the template.
|
||||||
|
|
||||||
|
## How PRs and issues are handled
|
||||||
|
|
||||||
|
Issues and pull requests live on GitHub. The maintainer's source of truth is
|
||||||
|
a self-hosted Gitea instance: open PRs are imported there, reviewed, and
|
||||||
|
merged there, and `main` is then mirrored back to GitHub. Two things follow:
|
||||||
|
|
||||||
|
- Keep discussion on the GitHub PR; that is where I'll reply.
|
||||||
|
- A merged change may show up on GitHub as the PR being closed with a link to
|
||||||
|
the commit rather than as "Merged". Your authorship is preserved.
|
||||||
|
- Imported PRs run `flutter analyze` and `flutter test` on my server, so
|
||||||
|
please run both locally first.
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Use the repository's template so history stays consistent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config commit.template .gitmessage
|
||||||
|
```
|
||||||
|
|
||||||
|
Write an imperative, sentence-case summary of 72 characters or fewer, then a
|
||||||
|
blank line and a short explanation of *why* when it isn't obvious.
|
||||||
|
|
||||||
|
## License of contributions
|
||||||
|
|
||||||
|
Noo is licensed under the [PolyForm Shield License 1.0.0](LICENSE). By
|
||||||
|
submitting a contribution you confirm that you wrote it (or have the right to
|
||||||
|
submit it), and you license it to the maintainer and everyone who receives the
|
||||||
|
project under those same terms. You also grant the maintainer the right to
|
||||||
|
relicense the project, including your contribution, in the future.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Please do not file security problems as public issues. Email
|
||||||
|
[hello@ayushya.dev](mailto:hello@ayushya.dev) with the details and I'll
|
||||||
|
respond as soon as I can.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
Required Notice: Copyright (c) 2026 Ayushya (https://ayushya.dev)
|
||||||
|
|
||||||
|
# PolyForm Shield License 1.0.0
|
||||||
|
|
||||||
|
<https://polyformproject.org/licenses/shield/1.0.0>
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||||
|
|
||||||
|
## Copyright License
|
||||||
|
|
||||||
|
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Distribution License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||||
|
|
||||||
|
## Notices
|
||||||
|
|
||||||
|
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||||
|
|
||||||
|
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||||
|
|
||||||
|
## Changes and New Works License
|
||||||
|
|
||||||
|
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||||
|
|
||||||
|
## Patent License
|
||||||
|
|
||||||
|
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||||
|
|
||||||
|
## Noncompete
|
||||||
|
|
||||||
|
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
||||||
|
|
||||||
|
## Competition
|
||||||
|
|
||||||
|
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
||||||
|
|
||||||
|
## New Products
|
||||||
|
|
||||||
|
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
||||||
|
|
||||||
|
## Discontinued Products
|
||||||
|
|
||||||
|
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
||||||
|
|
||||||
|
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
||||||
|
|
||||||
|
## Sales of Business
|
||||||
|
|
||||||
|
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
||||||
|
|
||||||
|
## Fair Use
|
||||||
|
|
||||||
|
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||||
|
|
||||||
|
## No Other Rights
|
||||||
|
|
||||||
|
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||||
|
|
||||||
|
## Patent Defense
|
||||||
|
|
||||||
|
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||||
|
|
||||||
|
## Violations
|
||||||
|
|
||||||
|
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||||
|
|
||||||
|
## No Liability
|
||||||
|
|
||||||
|
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||||
|
|
||||||
|
## Definitions
|
||||||
|
|
||||||
|
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||||
|
|
||||||
|
A **product** can be a good or service, or a combination of them.
|
||||||
|
|
||||||
|
**You** refers to the individual or entity agreeing to these terms.
|
||||||
|
|
||||||
|
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
||||||
|
|
||||||
|
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
||||||
|
|
||||||
|
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||||
|
|
||||||
|
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||||
|
|
||||||
|
**Use** means anything you do with the software requiring one of your licenses.
|
||||||
@@ -1,36 +1,46 @@
|
|||||||
# Noo
|
# Noo
|
||||||
|
|
||||||
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
|
A fast, native [Nextcloud](https://nextcloud.com) client built with Flutter
|
||||||
and Material You.
|
and Material 3.
|
||||||
|
|
||||||
Noo gives you a clean, modern way to browse and manage the files on your own
|
Noo is a clean, modern way to browse and manage the files on your own
|
||||||
Nextcloud server — files, photos, shares, activity, and trash, all in one
|
Nextcloud server: files, photos, shares, activity, and trash in one app,
|
||||||
app, themed to match your device.
|
themed to match your device and laid out for both phones and tablets.
|
||||||
|
|
||||||
|
Website: <https://noo.ayushya.dev>
|
||||||
|
|
||||||
|
> Noo is an independent, unofficial client. It is not affiliated with or
|
||||||
|
> endorsed by Nextcloud GmbH.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Files** — browse, upload, move, rename, delete, and favorite files and
|
- **Files**: browse, upload, move, copy, rename, delete, and favorite files
|
||||||
folders; sort and filter per folder
|
and folders; sort and filter per folder; list or grid view
|
||||||
- **Photos** — a dedicated media gallery with a full-screen viewer for images
|
- **Photos**: a media gallery with a full-screen viewer for images and video
|
||||||
and video
|
- **Viewer**: images, video, PDF (pinch to zoom), Markdown and text, with a
|
||||||
- **Activity** — a feed of what's changed on your server
|
pull-up details panel (info, activity, sharing)
|
||||||
- **Shares** — see and manage what you've shared and what's shared with you
|
- **Shares, Recent, Favorites, Activity, Trash**: see and manage what you
|
||||||
- **Trash** — restore or permanently delete recently removed items
|
have shared, what changed, and what you deleted
|
||||||
- **Material You theming** — dynamic color on supported devices, five
|
- **Search** across your server
|
||||||
built-in accent colors, light/dark/AMOLED
|
- **Offline and sync**: keep files available offline and sync chosen folders
|
||||||
- **Secure sign-in** — authenticates via Nextcloud's Login Flow v2: you sign
|
in the background
|
||||||
in through your browser, and Noo never sees or stores your password,
|
- **Multiple accounts**: switch between saved accounts from the avatar menu
|
||||||
only a scoped app token
|
- **App lock**: optionally require device authentication to open the app,
|
||||||
|
switch accounts, or reveal hidden files
|
||||||
## Screenshots
|
- **Share to Noo**: upload from any app's share sheet
|
||||||
|
- **Make it yours**: Material You dynamic color, five accent colors,
|
||||||
_Coming soon._
|
light/dark/AMOLED, attached or floating bottom bar, hamburger or avatar
|
||||||
|
navigation, adjustable upload button, and a tablet layout with a sidebar
|
||||||
|
- **Secure sign-in**: authenticates through Nextcloud's Login Flow v2. You
|
||||||
|
sign in through your browser, and Noo never sees or stores your password,
|
||||||
|
only a scoped app password
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
Prerequisites:
|
Prerequisites:
|
||||||
|
|
||||||
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
|
- [Flutter](https://docs.flutter.dev/get-started/install) (SDK `^3.12.2`)
|
||||||
|
- An Android device or emulator, an iOS device, or Windows
|
||||||
- A Nextcloud server with Login Flow v2 enabled (on by default since
|
- A Nextcloud server with Login Flow v2 enabled (on by default since
|
||||||
Nextcloud 15)
|
Nextcloud 15)
|
||||||
|
|
||||||
@@ -39,38 +49,83 @@ flutter pub get
|
|||||||
flutter run
|
flutter run
|
||||||
```
|
```
|
||||||
|
|
||||||
On first launch, enter your server's address — Noo opens your browser to
|
On first launch, enter your server's address. Noo opens your browser to
|
||||||
finish signing in.
|
finish signing in.
|
||||||
|
|
||||||
## Supported platforms
|
|
||||||
|
|
||||||
Android, iOS, and Windows.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter build apk # Android
|
flutter build apk # Android (debug-signed unless you configure a key)
|
||||||
flutter build ios # iOS
|
flutter build ios # iOS
|
||||||
flutter build windows # Windows
|
flutter build windows # Windows
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Release builds read signing details from `android/key.properties` or from
|
||||||
|
`RELEASE_KEYSTORE_*` environment variables (see `android/app/build.gradle.kts`).
|
||||||
|
Neither the keystore nor `key.properties` is, or should ever be, committed.
|
||||||
|
Without them, release builds fall back to debug signing.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
Flutter with Material 3, [`provider`](https://pub.dev/packages/provider) for
|
||||||
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV
|
state management, and `http`/`dio` talking directly to Nextcloud's WebDAV and
|
||||||
and OCS APIs — no bundled Nextcloud SDK.
|
OCS APIs, with no bundled Nextcloud SDK. Android has native workers for
|
||||||
|
transfers and background sync.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Project context for contributors (human or AI) lives in
|
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||||
[`CLAUDE.md`](CLAUDE.md) and `.claude/context/` — architecture, the server
|
first. In short:
|
||||||
integration, styling conventions, and code standards.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter test # run tests
|
flutter test # run tests
|
||||||
flutter analyze # static analysis / lints
|
flutter analyze # static analysis / lints
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Architecture, server integration, styling conventions, and code standards are
|
||||||
|
documented in [`CLAUDE.md`](CLAUDE.md) and `.claude/context/`, written for
|
||||||
|
both human and AI contributors.
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Noo talks only to the Nextcloud server you point it at. It has no analytics
|
||||||
|
and no third-party tracking. Read the full [privacy policy](https://noo.ayushya.dev/privacy).
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Typefaces [Instrument Sans](https://fonts.google.com/specimen/Instrument+Sans)
|
||||||
|
and [Schibsted Grotesk](https://fonts.google.com/specimen/Schibsted+Grotesk)
|
||||||
|
are bundled under the SIL Open Font License, and icons are from
|
||||||
|
[Lucide](https://lucide.dev). Those keep their own licenses; the license
|
||||||
|
below covers Noo's own code and assets.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Not yet licensed for redistribution — license TBD.
|
Noo is source-available under the
|
||||||
|
[PolyForm Shield License 1.0.0](LICENSE). In plain terms: you may read, build,
|
||||||
|
run, and modify the code, and contribute changes back, but you may not use it
|
||||||
|
to make a product or service that competes with Noo. Please do not republish
|
||||||
|
it as your own app.
|
||||||
|
|
||||||
|
Contributions are welcome under the terms in
|
||||||
|
[`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
|
|
||||||
|
### Why PolyForm Shield?
|
||||||
|
|
||||||
|
I want the code to be open to read, learn from, build, and improve, while
|
||||||
|
keeping anyone from lifting it and shipping a competing copy of the app.
|
||||||
|
Permissive licenses (MIT, Apache) allow exactly that, and copyleft ones
|
||||||
|
(GPL) still allow a rebranded fork. PolyForm Shield sits in between: it is
|
||||||
|
not an OSI "open source" license, and I'm not claiming it is, but it keeps
|
||||||
|
the code available while protecting the project.
|
||||||
|
|
||||||
|
## Where development happens
|
||||||
|
|
||||||
|
The source of truth is a self-hosted Gitea instance, and this GitHub
|
||||||
|
repository is a read-only mirror plus the public issue tracker. I keep it
|
||||||
|
that way so that I own my hosting, builds, and release credentials rather
|
||||||
|
than depending on a third party for them. You don't have to use Gitea:
|
||||||
|
open issues and pull requests here as usual. Open PRs are imported to my
|
||||||
|
instance, reviewed and merged there, and `main` is mirrored back here, so a
|
||||||
|
merged PR may show as closed with a link to the commit instead of "Merged".
|
||||||
|
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for details.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Security policy
|
||||||
|
|
||||||
|
If you find a security problem in Noo, please email
|
||||||
|
[hello@ayushya.dev](mailto:hello@ayushya.dev) instead of opening a public
|
||||||
|
issue. Include steps to reproduce and the version (`pubspec.yaml`). I'll
|
||||||
|
acknowledge reports as soon as I can and credit you in the fix if you'd like.
|
||||||
|
|
||||||
|
Never include real server URLs, credentials, or personal files in a report.
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 307 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 216 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 489 KiB |
|
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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 218 KiB |