Compare commits
@@ -57,15 +57,16 @@ new provider instance):
|
||||
saved-accounts list, which one is active, `sessionGeneration` — see
|
||||
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
||||
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
|
||||
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
|
||||
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
||||
controllers — constructed after `SessionController` and unable to hold a
|
||||
forward reference to it — can react to login/logout/account-switch
|
||||
without a circular dependency.
|
||||
- [`SettingsController`](../../lib/providers/settings_controller.dart) —
|
||||
global UI prefs independent of login state: theme mode/seed color/dynamic
|
||||
color, bottom-bar opacity/blur, tap-tab-to-scroll-top, seek bar style, tab
|
||||
color, bottom-bar opacity/blur, avatar and selection action-bar positions,
|
||||
tap-tab-to-scroll-top, seek bar style, tab
|
||||
order/visibility/default (`requestedTab`/`requestTab`/
|
||||
`consumeRequestedTab`), swipe actions.
|
||||
- [`FilesController`](../../lib/providers/files_controller.dart) — the
|
||||
@@ -282,11 +283,13 @@ the sidebar):
|
||||
- **Mobile:** `AppTopBar` (`widgets/app_top_bar.dart`) wraps `NooTopBar`
|
||||
for *every* tab (previously only Files had shell-level top chrome, with
|
||||
the rest building their own via `SyncedHeaderScaffold`) - iOS gets a
|
||||
large title, an inline search field, a `plus` action on Files only (no
|
||||
other tab has a create/upload flow), and the account avatar; Android
|
||||
large title with Upload and the top account avatar on the same row,
|
||||
and an inline search field below; Android
|
||||
gets a compact title row with `search`/avatar actions, relying on an
|
||||
extended `NooFab` ("Upload", Files/Photos only) instead of a top-bar
|
||||
icon for upload. Rather than one shared instance in `Scaffold.appBar`,
|
||||
extended `NooFab` instead of a top-bar upload button. Both use the same
|
||||
size preference: Auto expands on Files/Photos and collapses elsewhere,
|
||||
Mini always collapses, and Expanded always shows Upload. Rather than one
|
||||
shared instance in `Scaffold.appBar`,
|
||||
`MainShellView` builds a separate `AppTopBar` per tab (labelled for that
|
||||
tab) and each tab plants its own as the first sliver in its own
|
||||
`CustomScrollView` (`topBarSliver` in `widgets/tabs/tab_state_slivers.dart`,
|
||||
@@ -312,8 +315,13 @@ the sidebar):
|
||||
section yet). `SettingsController.navMenuStyle` (Settings → Appearance →
|
||||
"Navigation menu") offers an alternative to the hamburger/drawer pair:
|
||||
the avatar button opens `showAvatarMenu` (`widgets/avatar_menu.dart`)
|
||||
instead, a dropdown holding the same hidden-tabs + Settings content -
|
||||
see `styling.md`'s Gotchas for the wiring.
|
||||
instead. `AvatarNavigationHost` wraps the mobile scaffold to animate a
|
||||
card from the top or bottom avatar; `AvatarNavigationBody` moves only
|
||||
content for the top popup. The bottom popup overlays content. Floating
|
||||
mode moves the avatar into the popup and hands its satellite slot to
|
||||
Search; attached mode keeps the avatar and Search in the full-width bar
|
||||
and expands the popup upward from it. Position is a global setting.
|
||||
See `styling.md` for motion and dismissal behavior.
|
||||
- **Desktop:** a `NooSidebar` (account card, pinned tabs, divider,
|
||||
remaining tabs, storage meter, Settings) sits beside a `NooToolbar`
|
||||
(tab title, search, an "Upload" action on Files/Photos) over the same
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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.
|
||||
- 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
|
||||
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 |
|
||||
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
|
||||
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
|
||||
| Biometric label | "Lock with Face ID" | "Lock with fingerprint" |
|
||||
| App lock label | "App lock" | "App lock" |
|
||||
|
||||
### Desktop
|
||||
|
||||
@@ -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,
|
||||
share, download, delete, copy, move, rename, sync, details) - the
|
||||
first few (3 mobile / 4 desktop) land in the bar's fixed inline
|
||||
slots, the rest sit behind "More". See §2 "Selection action bar".
|
||||
slots, the rest sit behind "More". A live preview above the list uses
|
||||
the actual bar and updates on reorder, including its More sheet. Preview
|
||||
actions show feedback without touching files. See §2 "Selection action bar".
|
||||
9. Swipe on a file
|
||||
|
||||
Mobile is a two-level menu, the way native iOS/Android Settings apps
|
||||
|
||||
@@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too).
|
||||
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||
Flutter's own default to already be high enough.
|
||||
|
||||
**The three locks are independent** (Settings -> Security;
|
||||
`SessionController`): `loginLockEnabled` (unlock to open the app - the only one
|
||||
`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to
|
||||
switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden
|
||||
files). Each gate works with the others off - `passGate(gate, reason)`
|
||||
prompts whenever its own `gate` is on, whether or not login lock is. Turning
|
||||
any of them on **or off** needs a successful `AppLockService.authenticate`
|
||||
(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set;
|
||||
off needs it so someone with a momentarily unlocked phone can't remove it),
|
||||
and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle
|
||||
dependency is gone. Tests replace the prompt through
|
||||
`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`.
|
||||
|
||||
`isRestoringSession` still gates the splash screen until the above resolves
|
||||
— see `standards.md` for why widget tests must mock both storage channels
|
||||
rather than relying on this async path throwing naturally.
|
||||
@@ -811,3 +824,243 @@ rather than relying on this async path throwing naturally.
|
||||
bitmask (1 read, 2 update, 4 create, 8 delete, 16 reshare). The share
|
||||
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
|
||||
CI env vars (`RELEASE_KEYSTORE_PATH`/`_PASSWORD`, `RELEASE_KEY_ALIAS`/
|
||||
`_PASSWORD`, set from the same repo secrets by both `.gitea/workflows/
|
||||
build.yml`, triggered by `RC*` tags and producing a sideloadable APK, and
|
||||
`.gitea/workflows/release.yml`, triggered by `Release-*` tags and producing
|
||||
the `.aab` Play Console wants), then falls back to the debug key if neither
|
||||
build.yml`, triggered by `RC-Android-*` tags and producing a sideloadable
|
||||
APK, and `.gitea/workflows/release.yml`, triggered by `Release-*` tags and
|
||||
producing the `.aab` Play Console wants; `.gitea/workflows/ios.yml` builds
|
||||
and uploads the signed iOS app to TestFlight on `RC-iOS-*` and `Release-*`
|
||||
tags, on a self-hosted macOS runner), then falls back to the debug key if neither
|
||||
is configured. As long as the same dedicated release keystore backs both
|
||||
`key.properties` locally and the Gitea secrets, local release builds and
|
||||
CI-built release APKs share one signature, so `adb install -r` works
|
||||
|
||||
@@ -86,7 +86,7 @@ Catalog:
|
||||
|
||||
| Folder | Components |
|
||||
|---|---|
|
||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSlider`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooInfoNote` (tinted info callout; `SettingsSection(notice:)`), `NooSummaryCard`, `NooSelectionBar` |
|
||||
| `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` |
|
||||
@@ -111,6 +111,17 @@ Gotchas:
|
||||
in one gesture, it's not tap-only anymore.
|
||||
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
|
||||
built yet. `NooSidebar.windowControls` is the slot for it.
|
||||
- Swipe settings offer every `SelectionActionKind` plus None. `SwipeAction`
|
||||
persists the existing enum names and maps to that shared action catalog;
|
||||
`FilesView` uses the single-item action-bar handlers, with the existing
|
||||
delete confirmation retained. Labels/icons update for favorited and synced
|
||||
items. Swipes remain disabled during selection, picking, and offline browsing.
|
||||
- Settings → Action bar shows the real `NooSelectionBar` above its reorder
|
||||
list. It updates immediately and supports the same More sheet; preview
|
||||
actions only show feedback, never operate on files. The bar scales down
|
||||
inside Settings' narrower column while keeping the mobile/tablet split.
|
||||
- The Security setting is labelled **App lock** on every platform; its
|
||||
subtitle still explains device PIN or biometric authentication.
|
||||
- Always read colors through `context.nooColors`, never
|
||||
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
|
||||
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
|
||||
@@ -123,7 +134,8 @@ Gotchas:
|
||||
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
||||
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
||||
track blends invisibly into the sheet instead of reading as a grouped
|
||||
control (Files'/Photos' sort and type-filter sheets do this).
|
||||
control (Files'/Photos' filter sheets and Settings controls on surface
|
||||
rows use the same `surface2` track).
|
||||
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
||||
on its scrollable child, or pull-to-refresh silently can't be triggered
|
||||
once the list is short enough to fit the viewport (empty, or one item) -
|
||||
@@ -142,6 +154,19 @@ blocks are noted where they matter:
|
||||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||||
Offline), and reused as-is by Favorites.
|
||||
- Selection action bars in Files, Photos, and Favorites have a global
|
||||
Settings → Action bar → Position choice: Top (default) or Bottom,
|
||||
persisted as `ui_selection_bar_position`. Top stays pinned in the content
|
||||
header; Bottom uses `SelectionBarOverlay` above navigation (or the content
|
||||
pane's bottom on desktop), with extra list clearance. The overlay uses
|
||||
Scaffold's MediaQuery bottom padding so attached/floating/frosted navigation
|
||||
and safe areas are accounted for without counting their height twice.
|
||||
The shell hides Android's Upload FAB during selection via view callbacks.
|
||||
The mobile Action bar settings use one grouped card for Position and
|
||||
the reorder list, with no nested rounded corners or repeated page title.
|
||||
Their header backdrop is transparent, so content scrolls behind the inset
|
||||
`surface` card. A `line` border and `nooDialogShadow` match the floating
|
||||
bottom bar's solid treatment.
|
||||
- Hidden files and external storage are each a three-way segmented row
|
||||
(`HiddenFilesFilterRow`/`StorageScopeRow`, `lib/widgets/filter_mode_rows.dart`,
|
||||
in both the Files and Photos filter sheets), not toggles:
|
||||
@@ -149,7 +174,8 @@ blocks are noted where they matter:
|
||||
cloud (default) / external / all. Files and Photos each persist their own
|
||||
hidden filter (`ui_hidden_filter`, `ui_hidden_filter_photos`; the old
|
||||
`ui_show_hidden*` bools are still read as a fallback, `true` → include);
|
||||
`StorageScope` stays shared. Leaving `hide` is still behind the
|
||||
`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)
|
||||
@@ -248,16 +274,23 @@ blocks are noted where they matter:
|
||||
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||||
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||||
don't confuse the two: this one is still non-blurred, just inset with a
|
||||
`line` border and `nooDialogShadow` (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
|
||||
either). `floating` ignores `NooNavStyle` and always uses the Android row
|
||||
(icon-only idle tabs, sliding pill, 64px), so iOS matches Android there;
|
||||
only the attached bar still has a distinct iOS row. A separate, orthogonal
|
||||
`SettingsController.bottomBarFrosted` toggle (Settings → Appearance →
|
||||
"Frosted glass bottom bar", off by default, both platforms and both bar
|
||||
styles) swaps the bar's solid `surface` for a 0.72-alpha one over a
|
||||
20-sigma `BackdropFilter` (same sigma as `FrostedGlassContainer`) and drops
|
||||
`nooDialogShadow` (it would show through the glass). A frosted bar only
|
||||
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
|
||||
@@ -274,44 +307,50 @@ blocks are noted where they matter:
|
||||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||||
anywhere that needs the *current* cap on regular tabs.
|
||||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
||||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
||||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
||||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
||||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
||||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
||||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||||
opens the account switcher. In `avatarMenu` mode `main.dart` also sets
|
||||
`Scaffold.drawerEnableOpenDragGesture: false` so the edge swipe can't open
|
||||
the drawer. The menu header has a chevron that expands an account section
|
||||
(other saved accounts to switch to, "Add Account", "Manage Accounts")
|
||||
above the hidden tabs/Settings. `showAvatarMenu` is this app's first use of
|
||||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||||
not a modal flow) - there's no existing anchored-popup primitive here
|
||||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
||||
inset, not the top bar's full height on top of that) so it covers the
|
||||
top bar - including the tab title - rather than sitting below it, and
|
||||
its card carries two stacked `boxShadow`s rather than just
|
||||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
||||
to read as basically invisible on a small card over a dark theme's
|
||||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
||||
edge to be visible against an already-dark backdrop), so a second,
|
||||
tighter, more opaque contact shadow underneath it gives real elevation
|
||||
in both themes. The card's border can go missing wherever an opaque row
|
||||
sits against it too (every corner but the header's, which has no
|
||||
full-bleed fill of its own) if a `Container` combines `border` with its
|
||||
own `clipBehavior` - that paints the border as part of the *outer*
|
||||
decoration, then the clipped child on top right up to the same boundary,
|
||||
with no gap for the border's own stroke to show through. Fixed the same
|
||||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
||||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
||||
reduced by that same 1px) around the filled, clipped content instead, so
|
||||
it never paints over the border. Also added
|
||||
- `SettingsController.navMenuStyle` chooses hamburger/drawer or avatar popup
|
||||
navigation (Settings → Appearance → Navigation menu). Avatar mode exposes
|
||||
a persisted Avatar position choice (`ui_avatar_position`, Top by default).
|
||||
`AvatarNavigationHost` owns one 420ms open / 320ms close animation using
|
||||
`easeInOutCubicEmphasized`. The card expands from the real avatar anchor;
|
||||
its controls fade in after the expansion starts. Top moves the content pane
|
||||
down through `AvatarNavigationBody`, keeping bottom navigation stationary.
|
||||
Bottom moves the avatar upward into the card's bottom-right corner and
|
||||
overlays the list immediately above navigation. Both use the Files gutter.
|
||||
The floating rail beside a bottom avatar shows text only on the selected
|
||||
tab. Attached iOS also shows text only for the selected tab and keeps the full-width bar,
|
||||
with the avatar inside the trailing end. Its popup expands upward with
|
||||
full screen width, rounded top corners and no gap above the bar; the
|
||||
avatar stays in the bar, and Search keeps its inline position.
|
||||
The attached popup has no outer border or shadow, blending into the bar.
|
||||
All bottom popups order navigation (including Settings), current account,
|
||||
then expanded secondary accounts and Add/Manage Account controls.
|
||||
The iOS shell places Upload and the top avatar on the large-title row.
|
||||
Upload uses the shared `ui_fab_style` preference: Auto expands on Files
|
||||
and Photos and collapses elsewhere; Mini is icon-only; Expanded always
|
||||
shows the plus-and-Upload button. Upload uses the folder icon's tonal
|
||||
`accentSoft` background and `accentText` foreground. Search stays below.
|
||||
Upload uses the compact 32px size to match the visible top avatar.
|
||||
On iOS the accent picker hides the unsupported wallpaper color option;
|
||||
the accent subtitle, swatch and selection always reflect the seed color.
|
||||
Upload's width, padding and label opacity interpolate over `NooMotion.base`.
|
||||
A shared shell animation keeps separately mounted tab headers in sync
|
||||
during Auto tab changes; reduced motion skips the transition.
|
||||
Avatar navigation and its bottom satellite share the bottom bar's frost
|
||||
toggle, blur and opacity, including throughout the popup animation.
|
||||
The setting is labelled "Frosted glass" and also controls both media
|
||||
viewer panels through `FrostedGlassContainer`. Off uses an opaque surface
|
||||
without a backdrop filter; On uses the shared blur and tint opacity.
|
||||
Search starts inside the rail when a bottom avatar occupies the satellite;
|
||||
its row slot smoothly collapses as Search fades into that satellite while
|
||||
the avatar moves into the popup. Closing reverses the same path. The card
|
||||
retains hidden tabs, Settings, account switching, Add Account, and Manage
|
||||
Accounts. Account controls scroll on short screens. Tap outside, tap the
|
||||
popup avatar, or system Back to close. Reduced motion opens/closes without
|
||||
the transition. Drawer gestures are disabled in avatar mode. Standalone
|
||||
top bars outside the shell retain `showGeneralDialog` as a fallback.
|
||||
`ShellAvatarButton` keeps its vertical-swipe account shortcuts in either
|
||||
position. Its onTap override must have a matching tooltip/semantics label.
|
||||
Also added
|
||||
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||||
second search row under the way iOS's `search:` slot does, so an inline
|
||||
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||||
@@ -341,11 +380,13 @@ blocks are noted where they matter:
|
||||
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
||||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||||
for the video player's seek bar, plus a perpetually-animated
|
||||
for the non-iOS video player's seek bar, plus a perpetually-animated
|
||||
`SeekBarPreview` wrapper used by the Settings style picker so every
|
||||
preview always matches the real widget exactly (same painter, just fed
|
||||
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
||||
forking the painter.
|
||||
iOS uses `IosSeekBar` with `CupertinoSlider` instead and hides the style
|
||||
picker. Scrubbing pauses playback temporarily and restores its prior state.
|
||||
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
||||
back button + filename, the bottom action bar, the video transport
|
||||
controls) all share one small hand-rolled icon-button pattern
|
||||
@@ -384,11 +425,11 @@ blocks are noted where they matter:
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
# Deliberately not "on every push" - only when a release-candidate tag
|
||||
# (RC1, RC2, RC2026.1, ...) is pushed. Tag the commit you want built:
|
||||
# git tag RC1 && git push origin RC1
|
||||
# Deliberately not "on every push" - only when an Android release-candidate
|
||||
# tag (RC-Android-1, RC-Android-2, ...) is pushed. Tag the commit you want built:
|
||||
# git tag RC-Android-1 && git push origin RC-Android-1
|
||||
# iOS has its own RC-iOS-* tags (ios.yml); Release-* builds both platforms.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "RC*"
|
||||
- "RC-Android-*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -72,7 +73,7 @@ jobs:
|
||||
# talks to this same Gitea instance automatically via the
|
||||
# `github.token`/`github.server_url` context Gitea Actions
|
||||
# provides for compatibility. Release is named/tagged after
|
||||
# whatever tag triggered this run (e.g. "RC1").
|
||||
# whatever tag triggered this run (e.g. "RC-Android-1").
|
||||
uses: akkuman/gitea-release-action@v1
|
||||
with:
|
||||
files: ${{ env.APK_PATH }}
|
||||
|
||||
@@ -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"
|
||||
@@ -3,7 +3,7 @@ name: Build App Bundle
|
||||
# Deliberately not "on every push" - only when a real release tag (Release-1.0.0,
|
||||
# Release-1.2.3, ...) is pushed. Tag the commit you want built:
|
||||
# git tag Release-1.0.0 && git push origin Release-1.0.0
|
||||
# Unlike the RC* flow (build.yml), this builds the .aab Play Console wants,
|
||||
# Unlike the RC-Android-* flow (build.yml), this builds the .aab Play Console wants,
|
||||
# not a sideloadable .apk.
|
||||
on:
|
||||
push:
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
run: flutter analyze
|
||||
|
||||
- name: Decode release keystore
|
||||
# Same key as the RC* APK builds, so a Play Store upload's signature
|
||||
# Same key as the RC-Android-* APK builds, so a Play Store upload's signature
|
||||
# matches anything sideloaded from a release build. See
|
||||
# android/app/build.gradle.kts.
|
||||
run: echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > "${{ runner.temp }}/release-keystore.jks"
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 214 KiB |